@livx.cc/agentx 0.99.56 → 0.99.58

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/cli.js CHANGED
@@ -2561,7 +2561,7 @@ async function psGroupCpuSeconds(pgid) {
2561
2561
  const out = await new Promise((res, rej) => execFile4("ps", ["-A", "-o", "pid=,ppid=,pgid=,time="], { timeout: 5e3, env: { ...process.env, LC_ALL: "C" } }, (e, so) => e ? rej(e) : res(String(so))));
2562
2562
  return sumTreeCpu(out, pgid);
2563
2563
  } catch (e) {
2564
- log17.debug(`ps cpu probe failed for group ${pgid}: ${e}`);
2564
+ log22.debug(`ps cpu probe failed for group ${pgid}: ${e}`);
2565
2565
  return void 0;
2566
2566
  }
2567
2567
  }
@@ -2693,7 +2693,7 @@ function makeRealShellTool(options) {
2693
2693
  proc.stdout?.off?.("data", collect);
2694
2694
  proc.stderr?.off?.("data", collect);
2695
2695
  const id = registry.adopt(cmd, proc, out);
2696
- finish(handoffFor(timeoutMs, id, clean(out), !!registry.notifiesOnExit));
2696
+ finish(handoffFor(timeoutMs, id, clean2(out), !!registry.notifiesOnExit));
2697
2697
  };
2698
2698
  }
2699
2699
  const collect = (chunk) => {
@@ -2708,14 +2708,14 @@ function makeRealShellTool(options) {
2708
2708
  proc.stdout?.on("data", collect);
2709
2709
  proc.stderr?.on("data", collect);
2710
2710
  proc.on("error", (err2) => {
2711
- if (err2?.name === "AbortError" || ctl.signal.aborted) return finish(reasonFor(timedOut, timeoutMs, clean(out)));
2712
- log17.debug("shell spawn error", err2);
2713
- finish(`[exit 1] ${err2?.message ?? err2}${out ? "\n" + clean(out) : ""}`);
2711
+ if (err2?.name === "AbortError" || ctl.signal.aborted) return finish(reasonFor(timedOut, timeoutMs, clean2(out)));
2712
+ log22.debug("shell spawn error", err2);
2713
+ finish(`[exit 1] ${err2?.message ?? err2}${out ? "\n" + clean2(out) : ""}`);
2714
2714
  });
2715
2715
  proc.on("close", (code) => {
2716
2716
  flushEmit(ctx);
2717
- if (ctl.signal.aborted) return finish(reasonFor(timedOut, timeoutMs, clean(out)));
2718
- const body = clean(out);
2717
+ if (ctl.signal.aborted) return finish(reasonFor(timedOut, timeoutMs, clean2(out)));
2718
+ const body = clean2(out);
2719
2719
  if (code && code !== 0) return finish(`[exit ${code}]${body ? "\n" + body : ""}`);
2720
2720
  finish(body || "(command succeeded, no output)");
2721
2721
  });
@@ -2754,7 +2754,7 @@ function makeShellJobTools(registry) {
2754
2754
  if (out == null) return NO_JOB2(String(id));
2755
2755
  const st = registry.status(String(id));
2756
2756
  return `[${st.status}${st.exitCode != null ? ` exit ${st.exitCode}` : ""}${st.reason ? `: ${st.reason}` : ""}]
2757
- ${clean(out) || "(no output yet)"}`;
2757
+ ${clean2(out) || "(no output yet)"}`;
2758
2758
  }
2759
2759
  },
2760
2760
  {
@@ -2782,7 +2782,7 @@ ${clean(out) || "(no output yet)"}`;
2782
2782
  }
2783
2783
  ];
2784
2784
  }
2785
- var log17, clean, DETACHED, SECRET_ENV_RE, _spawn, ShellJobRegistry, shellStatus, NO_JOB2;
2785
+ var log22, clean2, DETACHED, SECRET_ENV_RE, _spawn, ShellJobRegistry, shellStatus, NO_JOB2;
2786
2786
  var init_tools_shell = __esm({
2787
2787
  "src/tools.shell.ts"() {
2788
2788
  "use strict";
@@ -2791,8 +2791,8 @@ var init_tools_shell = __esm({
2791
2791
  init_logging();
2792
2792
  init_shell_sandbox();
2793
2793
  init_tools_jobs();
2794
- log17 = forComponent("shell");
2795
- clean = (s) => truncateOutput(redactSecrets(s.replace(/\n+$/, "")));
2794
+ log22 = forComponent("shell");
2795
+ clean2 = (s) => truncateOutput(redactSecrets(s.replace(/\n+$/, "")));
2796
2796
  DETACHED = { stdio: ["ignore", "pipe", "pipe"], detached: true };
2797
2797
  SECRET_ENV_RE = /(_API_KEY|_TOKEN|_SECRET|_PASSWORD|_PRIVATE_KEY|^AWS_|^GITHUB_TOKEN$|^OPENAI_|^ANTHROPIC_|^GOOGLE_|^GEMINI_|^GROQ_|^NPM_TOKEN$)/i;
2798
2798
  ShellJobRegistry = class {
@@ -2877,7 +2877,7 @@ var init_tools_shell = __esm({
2877
2877
  notifyExit(id, meta, status, reason) {
2878
2878
  if (!this.cfg.onExit || status === "killed" && !reason?.startsWith("stuck")) return;
2879
2879
  try {
2880
- this.cfg.onExit({ id, command: meta.command, status: shellStatus(status), exitCode: meta.exitCode, tail: clean(this.jobs.output(id) ?? "").slice(-4e3), ...reason && status !== "done" ? { reason } : {} });
2880
+ this.cfg.onExit({ id, command: meta.command, status: shellStatus(status), exitCode: meta.exitCode, tail: clean2(this.jobs.output(id) ?? "").slice(-4e3), ...reason && status !== "done" ? { reason } : {} });
2881
2881
  } catch {
2882
2882
  }
2883
2883
  }
@@ -2939,7 +2939,7 @@ var init_tools_shell = __esm({
2939
2939
 
2940
2940
  // cli/cli.ts
2941
2941
  import { createInterface } from "readline/promises";
2942
- import { existsSync as existsSync9, readFileSync as readFileSync9, appendFileSync, mkdirSync as mkdirSync12, writeFileSync as writeFileSync9, readdirSync as readdirSync4, statSync as statSync5, unlinkSync as unlinkSync5 } from "fs";
2942
+ import { existsSync as existsSync9, readFileSync as readFileSync9, appendFileSync, mkdirSync as mkdirSync12, writeFileSync as writeFileSync9, readdirSync as readdirSync4, statSync as statSync6, unlinkSync as unlinkSync5 } from "fs";
2943
2943
  import { homedir as homedir10 } from "os";
2944
2944
  import { spawnSync as spawnSync6 } from "child_process";
2945
2945
 
@@ -3009,7 +3009,7 @@ function copyTextToClipboard(text, platform2 = process.platform) {
3009
3009
 
3010
3010
  // cli/cli.ts
3011
3011
  import { join as join14, resolve as resolve4, basename as basename2, extname, dirname as dirname5 } from "path";
3012
- import { AIClient, listModels, listProviders, getProviderFromModel, getModelInfo, resolveModel, isModelSupported, disposeCursorSessions, disposeClaudeCodeSessions } from "ai.libx.js";
3012
+ import { AIClient, DecideClient, listModels, listProviders, getProviderFromModel, getModelInfo, resolveModel, isModelSupported, disposeCursorSessions, disposeClaudeCodeSessions } from "ai.libx.js";
3013
3013
 
3014
3014
  // src/Agent.ts
3015
3015
  init_llm();
@@ -5168,6 +5168,10 @@ var Agent = class _Agent {
5168
5168
  args = tc.function.arguments;
5169
5169
  earlyError ??= `Error: invalid JSON arguments for ${tc.function.name}: ${String(e)}`;
5170
5170
  }
5171
+ if (!earlyError && tool && args && typeof args === "object") {
5172
+ const missing = (tool.parameters?.required ?? []).filter((k) => args[k] == null);
5173
+ if (missing.length) earlyError = `Error: ${tc.function.name} is missing required argument(s): ${missing.join(", ")} (got: ${Object.keys(args).join(", ") || "none"}). Re-call with the exact parameter names.`;
5174
+ }
5171
5175
  const hooks = this.activeHooks;
5172
5176
  const call = { name: tc.function.name, args };
5173
5177
  const meta = { id: tc.id };
@@ -5520,14 +5524,14 @@ function summarize(messages, focus) {
5520
5524
  if (lastAssistant) lines.push(`last assistant: ${lastAssistant}`);
5521
5525
  if (carried.length) lines.push("earlier recap:", ...carried.map((l) => ` ${l}`));
5522
5526
  if (focus?.trim()) {
5523
- const words = focus.toLowerCase().split(/\s+/).filter((w) => w.length > 2);
5527
+ const words2 = focus.toLowerCase().split(/\s+/).filter((w) => w.length > 2);
5524
5528
  const kept = [];
5525
5529
  const ordered = [...messages.filter((m) => !isSyntheticSummary(m)), ...messages.filter(isSyntheticSummary)];
5526
5530
  outer: for (const msg of ordered) {
5527
5531
  if (msg.role === "tool") continue;
5528
5532
  for (const line of contentText(msg.content).split("\n")) {
5529
5533
  const l = line.toLowerCase();
5530
- if (line.trim() && words.some((w) => l.includes(w))) {
5534
+ if (line.trim() && words2.some((w) => l.includes(w))) {
5531
5535
  kept.push(line.trim().slice(0, 200));
5532
5536
  if (kept.length >= 12) break outer;
5533
5537
  }
@@ -6485,6 +6489,130 @@ var EmotionStream = class {
6485
6489
  }
6486
6490
  };
6487
6491
 
6492
+ // node_modules/@bod.ee/voice/src/core/endpointing.ts
6493
+ var INCOMPLETE_ENDINGS = /* @__PURE__ */ new Set([
6494
+ // Conjunctions
6495
+ "and",
6496
+ "but",
6497
+ "or",
6498
+ "so",
6499
+ "because",
6500
+ "although",
6501
+ "though",
6502
+ "while",
6503
+ "since",
6504
+ "unless",
6505
+ "if",
6506
+ "when",
6507
+ "where",
6508
+ // Prepositions
6509
+ "to",
6510
+ "for",
6511
+ "with",
6512
+ "about",
6513
+ "of",
6514
+ "at",
6515
+ "in",
6516
+ "on",
6517
+ "from",
6518
+ "by",
6519
+ "into",
6520
+ "through",
6521
+ // Articles / determiners
6522
+ "the",
6523
+ "a",
6524
+ "an",
6525
+ "this",
6526
+ "that",
6527
+ "these",
6528
+ "those",
6529
+ "my",
6530
+ "your",
6531
+ "his",
6532
+ "her",
6533
+ "our",
6534
+ "their",
6535
+ // Bare pronouns ("…as I asked it" — the live case above)
6536
+ "i",
6537
+ "you",
6538
+ "we",
6539
+ "they",
6540
+ "he",
6541
+ "she",
6542
+ "it",
6543
+ // Fillers (thinking)
6544
+ "um",
6545
+ "uh",
6546
+ "like",
6547
+ "well"
6548
+ ]);
6549
+ var CONTINUATION_MARKERS = [
6550
+ "you know",
6551
+ "i mean",
6552
+ "the thing is",
6553
+ "let me think",
6554
+ "first of all",
6555
+ "on one hand",
6556
+ "for example"
6557
+ ];
6558
+ var COMPLETE_ENDINGS = /* @__PURE__ */ new Set([
6559
+ "yeah",
6560
+ "yes",
6561
+ "no",
6562
+ "nah",
6563
+ "okay",
6564
+ "ok",
6565
+ "sure",
6566
+ "right",
6567
+ "thanks",
6568
+ "bye",
6569
+ "absolutely",
6570
+ "definitely",
6571
+ "exactly",
6572
+ "correct",
6573
+ "true",
6574
+ "false"
6575
+ ]);
6576
+ function isTranscriptComplete(text) {
6577
+ const trimmed = text.trim();
6578
+ if (!trimmed) return false;
6579
+ if (/[.?!]$/.test(trimmed)) return true;
6580
+ const words2 = trimmed.toLowerCase().split(/\s+/);
6581
+ const lastWord = words2[words2.length - 1];
6582
+ if (COMPLETE_ENDINGS.has(lastWord)) return true;
6583
+ if (INCOMPLETE_ENDINGS.has(lastWord)) return false;
6584
+ const lower = trimmed.toLowerCase();
6585
+ for (const marker of CONTINUATION_MARKERS) if (lower.endsWith(marker)) return false;
6586
+ if (words2.length <= 2) return false;
6587
+ return true;
6588
+ }
6589
+ var MAX_HOLD_MS = 2e3;
6590
+ var MIN_MERGE_HOLD_MS = 800;
6591
+ function endpointTier(transcript) {
6592
+ const t = transcript.trim();
6593
+ if (!isTranscriptComplete(t)) return 1;
6594
+ return /[.?!]$/.test(t) ? 3 : 2;
6595
+ }
6596
+ function tierHoldMs(tier, ctx) {
6597
+ if (tier === 1) return ctx.incompleteMs;
6598
+ if (tier === 2) return Math.round((ctx.baseMs + ctx.incompleteMs) / 2);
6599
+ return ctx.baseMs;
6600
+ }
6601
+ function computeHoldMs(ctx) {
6602
+ const t = ctx.transcript.trim();
6603
+ const tier = endpointTier(t);
6604
+ let ms = tierHoldMs(tier, ctx);
6605
+ const ai = ctx.lastAiMessage?.trim();
6606
+ if (ai) {
6607
+ if (/\?$/.test(ai)) ms -= 300;
6608
+ if (ai.split(/\s+/).length > 60) ms += 400;
6609
+ }
6610
+ const lastWord = t.toLowerCase().split(/\s+/).pop();
6611
+ if (lastWord === "um" || lastWord === "uh" || lastWord === "hmm") ms += 500;
6612
+ const floor = tier === 3 ? ctx.baseMs : Math.max(ctx.baseMs, Math.min(tierHoldMs(tier, ctx), MIN_MERGE_HOLD_MS));
6613
+ return Math.max(floor, Math.min(MAX_HOLD_MS, ms));
6614
+ }
6615
+
6488
6616
  // node_modules/@bod.ee/voice/src/core/engine.ts
6489
6617
  var log12 = forComponent2("VoiceEngine");
6490
6618
  var realClock = {
@@ -6493,7 +6621,7 @@ var realClock = {
6493
6621
  clearTimeout: (h) => clearTimeout(h)
6494
6622
  };
6495
6623
  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, ".");
6496
- var normWord = (w) => w.toLowerCase().replace(/[^a-z0-9]/g, "");
6624
+ var normWord = (w) => w.toLowerCase().replace(/[^\p{L}\p{M}\p{N}]/gu, "");
6497
6625
  var SPEC_TRAILING_OK = /* @__PURE__ */ new Set(["please", "thanks", "thank", "you", "now", "okay", "ok", "kindly", "alright", "then", "though", "right", "yeah"]);
6498
6626
  function speculationConfirms(spec, final) {
6499
6627
  const sw = spec.trim().split(/\s+/).map(normWord).filter(Boolean);
@@ -6504,11 +6632,27 @@ function speculationConfirms(spec, final) {
6504
6632
  for (let i = sw.length; i < fw.length; i++) if (!SPEC_TRAILING_OK.has(fw[i])) return false;
6505
6633
  return true;
6506
6634
  }
6635
+ function heardPrefix(text, spoken, lookahead = 4) {
6636
+ const toks = [...text.matchAll(/\S+/g)];
6637
+ let i = 0, at = 0;
6638
+ for (const w of spoken) {
6639
+ const nw = normWord(w);
6640
+ if (!nw) continue;
6641
+ for (let k = i; k < Math.min(toks.length, i + lookahead); k++)
6642
+ if (normWord(toks[k][0]) === nw) {
6643
+ at = toks[k].index;
6644
+ i = k + 1;
6645
+ break;
6646
+ }
6647
+ }
6648
+ return at;
6649
+ }
6507
6650
  var VoiceEngineOptions = class {
6508
6651
  stt;
6509
6652
  tts;
6510
6653
  player;
6511
- /** a final utterance arrived (endpoint) — host dispatches it as a turn */
6654
+ /** a final utterance arrived (endpoint) — host dispatches it as a turn. `meta.supersedes` marks a fuller
6655
+ * re-finalization of the utterance dispatched just before it: replace that turn, don't add one. */
6512
6656
  onUtterance = () => {
6513
6657
  };
6514
6658
  /** live partial transcript while listening (host renders the 🎤 line) */
@@ -6516,6 +6660,14 @@ var VoiceEngineOptions = class {
6516
6660
  };
6517
6661
  onState = () => {
6518
6662
  };
6663
+ /** The assistant's spoken turn finished AUDIBLY — every TTS chunk arrived AND playback drained.
6664
+ * Fires from the same `settle()` that returns the engine to 'listening', just before that state
6665
+ * change. Distinct from the host's `endSpeech()`, which only closes the TEXT stream (audio is
6666
+ * still playing at that point). NOT fired for a turn cut by barge-in — the host gets `onBargeIn`
6667
+ * instead. Also fires for engine-internal speech (adaptive ack, filler, queued utterances), so a
6668
+ * host that only cares about ITS turns should gate on its own open-turn flag. Default no-op. */
6669
+ onSpeechEnd = () => {
6670
+ };
6519
6671
  /** user spoke/acted over playback — host aborts the in-flight turn (called AFTER audio is killed).
6520
6672
  * phase: 'speaking' = cut mid-speech (real interruption); 'drain' = in the final audio tail
6521
6673
  * (normal turn-taking — hosts shouldn't alarm). */
@@ -6530,6 +6682,14 @@ var VoiceEngineOptions = class {
6530
6682
  /** the adaptive ack actually fired (host can mark the turn as spoken — e.g. suppress dead-air repair) */
6531
6683
  onAdaptiveAck = () => {
6532
6684
  };
6685
+ /** HOST-CHOSEN adaptive ack: asked once per armed ack (at beginSpeech(true), i.e. at dispatch) so a slow pick
6686
+ * runs in parallel with the reflex. Return the phrase to speak, or `null` = stay silent this turn. A Promise
6687
+ * still pending when the ack is due gets up to another `adaptiveAckMs` (then the turn stays silent — a wrong
6688
+ * ack is worse than none); a rejection falls back to the default pools. Unset (null) = the engine's own
6689
+ * shape-picked pools (ACKS_NEUTRAL / ACKS_QUESTION). Phrases enter the echo set like the defaults — keep them
6690
+ * short and not a lone word users say on their own ("okay", "yeah"): the ack-squash guard drops a user
6691
+ * utterance that equals the last ack. */
6692
+ pickAck = null;
6533
6693
  /** Endpoint merge window (ms): hold an endpointed utterance briefly — if speech resumes (spelled
6534
6694
  * letters, mid-thought pauses), the next utterance MERGES instead of dispatching a truncated one
6535
6695
  * ("E-L-Y." / "A."). Costs this much latency per turn; 0 disables. */
@@ -6537,6 +6697,21 @@ var VoiceEngineOptions = class {
6537
6697
  /** Extended merge window (ms) for utterances that look incomplete (trailing conjunction/filler).
6538
6698
  * Gives the user time to finish their thought without triggering a model call. */
6539
6699
  incompleteMergeMs = 1500;
6700
+ /** ADAPTIVE endpoint hold (opt-in; default OFF = today's byte-identical two-tier behavior).
6701
+ * The fixed merge tiers (`utteranceMergeMs` vs `incompleteMergeMs`, chosen by a trailing-word
6702
+ * regex, and skipped entirely at ≥4 words) dispatch mid-thought FRAGMENTS as their own turn: live,
6703
+ * "…give me the best" dispatched, got an empty reply, and "…give me the best of it." arrived 766ms
6704
+ * later as a second turn. When true, every endpointed utterance is held for
6705
+ * `computeHoldMs(...)` — sized by linguistic completeness, whether the STT emitted a sentence-final
6706
+ * mark, and what the assistant last said — so the continuation MERGES instead of burning a turn.
6707
+ * COST — this is strictly slower than the default path, never faster: today every ≥4-word utterance
6708
+ * dispatches at 0ms (the fast path below), so adaptive ADDS the whole hold — ~350ms to every
6709
+ * sentence-final utterance, ~925ms to every complete-looking unpunctuated one (the common case),
6710
+ * and up to 2s on a mid-thought fragment. Paid on turns that didn't need it, to stop burning a turn
6711
+ * (empty reply + blank bubble) on the ones that did — hence opt-in. Also changes `onHold` timing:
6712
+ * it now fires on every tier-2/3 hold, not only on `looksIncomplete` (`holdFiller` stays tier-1
6713
+ * only). See core/endpointing.ts. */
6714
+ adaptiveEndpointing = false;
6540
6715
  /** Grace window (ms) after an utterance dispatches, during which the user's own trailing audio cannot
6541
6716
  * barge the reply it requested. Soniox keeps finalizing partials past <end>; without this they read
6542
6717
  * as a barge and abort the fresh turn (live: mid-sentence self-interruption + steps=1→steps=0 double
@@ -6547,6 +6722,15 @@ var VoiceEngineOptions = class {
6547
6722
  * while audible (speaking + drain tail), so echo can never become a phantom turn — the right mode
6548
6723
  * when there's no AEC (e.g. the non-VPIO mic fallback) and no headphones. Cost: can't interrupt. */
6549
6724
  bargeIn = true;
6725
+ /** Which barge-in TIER to run — the host's override of the mic's self-report.
6726
+ * - `'auto'` (default) — trust `stt.usingAec`: AEC tier when the mic reports echo cancellation
6727
+ * applied, heuristic/energy tier otherwise. Unchanged legacy behavior.
6728
+ * - `'aec'` — force the AEC tier (STT stays live through TTS; barge decided by the dominant-novel
6729
+ * gate + overlap pause) even if the mic reports no echo cancellation.
6730
+ * - `'energy'` — force the heuristic tier (echo-baseline EMA spike arms `suspectUntil`, novel-word
6731
+ * count decides; STT buffer reset after our own audio) even if the mic REPORTS echoCancellation.
6732
+ * Mobile Safari reports `true` while cancelling poorly — that report is what this overrides. */
6733
+ bargeInMode = "auto";
6550
6734
  /** Filler phrase spoken when holding for an incomplete utterance ('' disables). */
6551
6735
  holdFiller = "";
6552
6736
  /** Called when the engine holds an incomplete utterance (host can render a visual cue). */
@@ -6570,6 +6754,15 @@ var VoiceEngineOptions = class {
6570
6754
  * Mechanism-based discriminator: a re-PAUSE this soon after a resume = a persistent human, not an
6571
6755
  * echo blip (which pauses once and stalls). Cede on the re-pause regardless of the novel gate. */
6572
6756
  overlapRepauseCedeMs = 1500;
6757
+ /** HOST-DECIDED short overlaps (opt-in; 0 = off = cede on ≥2 genuine words, as always). The fast "≥2 novel words →
6758
+ * cede" rule cannot tell a reaction ("Oh, that's new") from a barge, and it fires ~300ms before a host-side judge
6759
+ * (e.g. an AI instinct) can answer — so a judge could never keep a delivery going (live 20260923-125143: ceded on
6760
+ * "Oh, that", the judge said keep_talking 300ms later). When > 0, a SHORT genuine overlap (≤ overlapDecideMaxWords,
6761
+ * no '?', no stop/question word) keeps the pause and waits for `decideOverlap()`; with no decision after this many
6762
+ * ms it cedes. Long or obvious barges still cede instantly. Audio is already paused, so waiting costs no talk-over. */
6763
+ overlapDecideMs = 0;
6764
+ /** Longest overlap (words) the host decides; also how far an overlap the host kept may grow before it cedes. */
6765
+ overlapDecideMaxWords = 4;
6573
6766
  /** Speculative ENERGY pre-pause while speaking (AEC tier): two residue gate-passes within 350ms →
6574
6767
  * pause ~300ms before the STT tokens land. But energy CANNOT separate residue bursts from speech
6575
6768
  * (the documented whack-a-mole) — so a residue spike during loud playback false-pauses with NO user
@@ -6622,6 +6815,10 @@ var VoiceEngineOptions = class {
6622
6815
  * timestamp-emitting TTS (CartesiaTTS.wantTimestamps) driven over `AudioSink.playedMs`.
6623
6816
  */
6624
6817
  revealMode = "off";
6818
+ /** Locate EXACTLY what the user heard when a barge cuts speech: request word timestamps from the TTS (when it
6819
+ * supports them) and match them against the playback clock (InterruptedReply `heard`/`exact`). Independent of
6820
+ * `revealMode` — no reveal events. Without timestamps the cut falls back to the ~15 chars/s estimate. */
6821
+ heardTimestamps = true;
6625
6822
  /** Called with the cumulative revealed text for the current assistant turn (see `revealMode`).
6626
6823
  * Reset to '' at the start of each spoken turn. */
6627
6824
  onReveal = () => {
@@ -6652,8 +6849,27 @@ var VoiceEngine = class _VoiceEngine {
6652
6849
  // cumulative revealed (on-screen) text for the current turn — see revealMode
6653
6850
  // 'word' karaoke reveal: spoken-word start times (sec, absolute from turn-audio start) + poll state.
6654
6851
  wordStarts = [];
6852
+ wordTexts = [];
6853
+ // the timestamped words, parallel to wordStarts (heard-cut matching)
6854
+ ackWords = 0;
6855
+ // leading timestamped words that are the ack spoken on this turn's context, not the reply
6655
6856
  revealedN = 0;
6656
6857
  revealPoll = null;
6858
+ // Sub-turn timestamp anchoring. A TTS context emits word starts relative to ITS OWN audio start, so
6859
+ // `wordStarts` (one flat turn timeline) is only valid while the turn stays on one context. It does
6860
+ // not: `subturnBoundary()` rotates the context, and Cartesia ALSO rotates it on its own (~5s idle
6861
+ // timeout mid tool-call) — after which starts restart at 0 and every remaining word instantly
6862
+ // satisfies the reveal predicate, dumping the whole segment on screen.
6863
+ // Fix is deterministic, not detective: at a boundary we FORCE the rotation ourselves, so the new
6864
+ // context's origin is always known — it is the turn-audio position where its first byte lands,
6865
+ // captured from the sink at that byte (`playedMs + drainMs` = content seconds written so far).
6866
+ // Starts arriving before that byte are held in `wordPending` and rebased when it does.
6867
+ wordOrigin = 0;
6868
+ // sec offset added to incoming starts (0 = first sub-turn)
6869
+ wordAnchorPending = false;
6870
+ // awaiting the new sub-turn's first audio byte
6871
+ wordPending = [];
6872
+ // [word, start] received while pending, rebased on capture
6657
6873
  clock;
6658
6874
  drainTimer = null;
6659
6875
  // heuristic tier state (inert under AEC) — frozen as validated in the experiment
@@ -6684,6 +6900,8 @@ var VoiceEngine = class _VoiceEngine {
6684
6900
  // endpointed text held for the merge window
6685
6901
  mergePath = "direct";
6686
6902
  // how the pending utterance was assembled (diag)
6903
+ holdTier = 0;
6904
+ // adaptive tier of the hold this dispatch came out of (diag; 0 = none)
6687
6905
  lastGraceDiag = 0;
6688
6906
  // grace_suppress emitted once per grace window
6689
6907
  pendingTimer = null;
@@ -6694,10 +6912,24 @@ var VoiceEngine = class _VoiceEngine {
6694
6912
  // the agent replied (or after 3s) still dispatches.
6695
6913
  lastDispatchFlat = "";
6696
6914
  lastDispatchWords = [];
6915
+ lastDispatchText = "";
6697
6916
  lastDispatchAt = 0;
6698
6917
  repliedSinceDispatch = false;
6918
+ dispatchReply = "";
6919
+ // the agent's prose since the last dispatch (answeredSinceDispatch)
6699
6920
  static DUP_FINAL_MS = 3e3;
6700
6921
  lastInterrupted = null;
6922
+ queuedTurn = false;
6923
+ // the current spoken turn is a queued worker utterance (pumpQueue), not a host turn
6924
+ // host-decided overlap (options.overlapDecideMs): pending decision timer, a final that landed while waiting, and
6925
+ // the window in which the overlap the host chose to talk over is absorbed (its growth + its final)
6926
+ decideTimer = null;
6927
+ decideSince = 0;
6928
+ // when this hold began waiting for the host — caps re-decisions (3× overlapDecideMs)
6929
+ decideHeldFinal = "";
6930
+ overlapKeptUntil = 0;
6931
+ overlapKept = "";
6932
+ // what the host judged keep_talking on — only THOSE words are absorbed
6701
6933
  // overlap (pause) tier state — AEC + pause-capable sinks only
6702
6934
  pausedAt = 0;
6703
6935
  lastResumeAt = 0;
@@ -6746,13 +6978,21 @@ var VoiceEngine = class _VoiceEngine {
6746
6978
  }
6747
6979
  async start() {
6748
6980
  this.tts.onAudio = (c) => {
6749
- if (this.speaking || this.bcActive) this.player.write(c);
6981
+ if (!this.speaking && !this.bcActive) return;
6982
+ if (this.wordAnchorPending && this.speaking) this.anchorSubturn();
6983
+ this.player.write(c);
6750
6984
  };
6751
- if (this.options.revealMode === "word") {
6985
+ if (this.options.revealMode === "word" || this.options.heardTimestamps) {
6752
6986
  this.tts.wantTimestamps = true;
6753
- this.tts.onTimestamps = (_words, start) => {
6987
+ this.tts.onTimestamps = (words2, start) => {
6754
6988
  if (this.interrupted) return;
6755
- for (const s of start) this.wordStarts.push(s);
6989
+ start.forEach((s, i) => {
6990
+ if (this.wordAnchorPending) this.wordPending.push([words2[i] ?? "", s]);
6991
+ else {
6992
+ this.wordStarts.push(this.wordOrigin + s);
6993
+ this.wordTexts.push(words2[i] ?? "");
6994
+ }
6995
+ });
6756
6996
  };
6757
6997
  }
6758
6998
  this.stt.onPartial = (text) => this.handlePartial(text);
@@ -6761,10 +7001,15 @@ var VoiceEngine = class _VoiceEngine {
6761
7001
  await Promise.all([this.tts.connect(), this.stt.start()]);
6762
7002
  this.tts.warmup?.();
6763
7003
  this.setState("listening");
6764
- log12.debug(`voice I/O up (${this.stt.usingAec ? "AEC" : "heuristic echo"} capture)`);
7004
+ if (this.options.bargeInMode === "aec" && !this.stt.usingAec)
7005
+ log12.warn("bargeInMode=aec but mic does not report echoCancellation applied \u2014 STT may transcribe the assistant");
7006
+ log12.debug(`voice I/O up (${this.usingAec ? "AEC" : "heuristic echo"} capture, mode=${this.options.bargeInMode}, mic reports aec=${this.stt.usingAec})`);
6765
7007
  }
7008
+ /** The RESOLVED barge-in tier (the single seam every tier branch reads). `bargeInMode` overrides
7009
+ * the mic's self-report — see the option doc for why the report is not trustworthy on mobile. */
6766
7010
  get usingAec() {
6767
- return this.stt.usingAec;
7011
+ const m = this.options.bargeInMode;
7012
+ return m === "aec" ? true : m === "energy" ? false : this.stt.usingAec;
6768
7013
  }
6769
7014
  /** Flip barge-in at runtime (e.g. the mic fell back to non-VPIO → go half-duplex so echo can't leak). */
6770
7015
  setBargeIn(on) {
@@ -6796,6 +7041,12 @@ var VoiceEngine = class _VoiceEngine {
6796
7041
  for (const r of this.idleWaiters.splice(0)) r();
6797
7042
  }
6798
7043
  }
7044
+ /** True while the assistant is AUDIBLE — deltas flowing or queued audio still draining. Goes false
7045
+ * exactly when `onSpeechEnd` fires (or immediately on barge-in). Lets a host tell "the turn closed
7046
+ * but audio is still playing" from "there was nothing to play", without waiting for a callback. */
7047
+ get audible() {
7048
+ return this.speaking;
7049
+ }
6799
7050
  /** Resolve when the engine is no longer speaking (immediate if already idle). */
6800
7051
  awaitIdle() {
6801
7052
  if (this.state !== "speaking" && this.state !== "thinking") return Promise.resolve();
@@ -6812,19 +7063,31 @@ var VoiceEngine = class _VoiceEngine {
6812
7063
  this.drainTimer = null;
6813
7064
  }
6814
7065
  this.interrupted = false;
7066
+ this.queuedTurn = false;
6815
7067
  this.bcSupersede();
6816
7068
  this.resetOverlap(true);
7069
+ const acked = ack && this.dispatchRefinal && this.ackSpent;
7070
+ if (acked) {
7071
+ ack = false;
7072
+ this.diag("ack_skipped", { reason: "refinal" });
7073
+ }
6817
7074
  this.diag("tts_turn_begin", { ack, gapless: this.speaking });
7075
+ const gapless = this.speaking;
6818
7076
  if (!this.speaking) this.player.markTurn();
6819
7077
  this.speaking = true;
6820
7078
  this.ctxOpen = true;
6821
7079
  this.spokeDeltas = false;
6822
- this.adaptiveAckFired = false;
6823
- this.ackProbe = null;
7080
+ this.adaptiveAckFired = acked;
7081
+ this.ackProbe = acked ? "" : null;
7082
+ this.ackPickedFired = false;
6824
7083
  this.reply = "";
6825
7084
  this.revealText = "";
6826
7085
  this.wordStarts = [];
7086
+ this.wordTexts = [];
7087
+ this.ackWords = 0;
6827
7088
  this.revealedN = 0;
7089
+ this.resetSubturnAnchor();
7090
+ if (gapless) this.wordAnchorPending = true;
6828
7091
  if (this.options.revealMode === "word") this.startWordReveal();
6829
7092
  this.emo = this.options.emotions ? new EmotionStream(this.options.showEmotions) : null;
6830
7093
  this.echoWords = new Set(this.words(this.prevReply));
@@ -6836,23 +7099,27 @@ var VoiceEngine = class _VoiceEngine {
6836
7099
  this.spokeDeltas = true;
6837
7100
  this.ackAt = this.clock.now();
6838
7101
  this.lastAck = this.options.ackPhrase;
7102
+ this.ackSpent = true;
7103
+ this.ackWords = this.options.ackPhrase.trim().split(/\s+/).length;
6839
7104
  for (const w of this.words(this.options.ackPhrase)) this.echoWords.add(w);
6840
7105
  } else if (ack && this.options.adaptiveAckMs > 0) {
7106
+ const pick = this.startAckPick();
7107
+ const ms = this.options.adaptiveAckMs;
6841
7108
  this.ackTimer = this.clock.setTimeout(() => {
6842
7109
  this.ackTimer = null;
6843
- if (!this.speaking || !this.ctxOpen || this.spokeDeltas || this.interrupted) return;
6844
- const phrase = this.pickAck();
6845
- this.diag("ack_fired", { phrase, adaptive: true, waitedMs: this.options.adaptiveAckMs });
6846
- this.tts.speak(phrase + " ", true);
6847
- this.spokeDeltas = true;
6848
- this.ackAt = this.clock.now();
6849
- this.lastAck = phrase;
6850
- for (const w of this.words(phrase)) this.echoWords.add(w);
6851
- this.setState("speaking");
6852
- this.adaptiveAckFired = true;
6853
- this.ackProbe = "";
6854
- this.options.onAdaptiveAck();
6855
- }, this.options.adaptiveAckMs);
7110
+ if (!pick.pending) return this.fireAck(pick.phrase, pick.picked);
7111
+ this.ackTimer = this.clock.setTimeout(() => {
7112
+ this.ackTimer = null;
7113
+ pick.onDone = null;
7114
+ this.ackPick = null;
7115
+ this.diag("ack_cancelled", { reason: "pick_late" });
7116
+ }, ms);
7117
+ pick.onDone = () => {
7118
+ this.clearAckTimer();
7119
+ this.fireAck(pick.phrase, pick.picked);
7120
+ };
7121
+ }, ms);
7122
+ this.ackPick = pick;
6856
7123
  }
6857
7124
  if (!this.turnStartAt) this.turnStartAt = this.clock.now();
6858
7125
  this.setState("thinking");
@@ -6872,11 +7139,16 @@ var VoiceEngine = class _VoiceEngine {
6872
7139
  this.adaptiveAckFired = false;
6873
7140
  const lead = m ? m[1] : bare;
6874
7141
  const norm2 = lead.replace(/[^a-z ]/gi, " ").replace(/\s+/g, " ").trim().toLowerCase();
7142
+ const same = this.ackPickedFired ? bare.match(_VoiceEngine.leadingPhrase(this.lastAck)) : null;
6875
7143
  if (_VoiceEngine.ACK_LEADING.test(norm2)) {
6876
7144
  const rest = m ? m[2] : "";
6877
7145
  this.diag("reflex_ack_suppressed", { lead: lead.trim() });
6878
7146
  if (!rest.trim()) return "";
6879
7147
  text = rest;
7148
+ } else if (same) {
7149
+ this.diag("reflex_ack_suppressed", { lead: same[0].trim() });
7150
+ text = bare.slice(same[0].length);
7151
+ if (!text.trim()) return "";
6880
7152
  } else {
6881
7153
  text = probe;
6882
7154
  }
@@ -6889,7 +7161,10 @@ var VoiceEngine = class _VoiceEngine {
6889
7161
  prose = " " + prose;
6890
7162
  }
6891
7163
  this.reply += prose;
6892
- if (prose.trim()) this.repliedSinceDispatch = true;
7164
+ if (prose.trim()) {
7165
+ this.repliedSinceDispatch = true;
7166
+ this.dispatchReply += prose;
7167
+ }
6893
7168
  for (const w of this.words(this.reply)) this.echoWords.add(w);
6894
7169
  this.tts.speak(forSpeech(speech), true);
6895
7170
  if (!this.spokeDeltas && this.turnStartAt) log12.debug(`ttft: ${Math.round(this.clock.now() - this.turnStartAt)}ms`);
@@ -6901,6 +7176,42 @@ var VoiceEngine = class _VoiceEngine {
6901
7176
  }
6902
7177
  return display;
6903
7178
  }
7179
+ /** Sub-turn seam: the host's agent stream interrupted the reply with a tool call, so the spoken
7180
+ * turn continues on a FRESH TTS context. Flushes the current context and opens the next, then
7181
+ * re-anchors the karaoke timeline at the position the next context's first audio byte lands on.
7182
+ *
7183
+ * Rotating unconditionally is the point: Cartesia restarts word timestamps at 0 for each context
7184
+ * and ALSO rotates on its own after ~5s idle — so leaving the context alone makes reveal timing
7185
+ * depend on an invisible server-side timer (short gap = one timeline, long gap = a restart we
7186
+ * never saw). By always rotating, the origin is always known and never inferred.
7187
+ *
7188
+ * No-op outside an open turn. Reveal text/word ordering is unchanged — only the time origin. */
7189
+ subturnBoundary() {
7190
+ if (!this.speaking || !this.ctxOpen) return;
7191
+ this.diag("tts_subturn_boundary", { playedMs: Math.round(Math.max(0, this.player.playedMs())) });
7192
+ this.tts.end();
7193
+ this.tts.newContext();
7194
+ this.wordAnchorPending = true;
7195
+ this.wordPending = [];
7196
+ }
7197
+ /** Capture the current sub-turn's time origin = content seconds already written to the sink.
7198
+ * `playedMs + drainMs` is that sum in both regimes (mid-playback it telescopes to the scheduled
7199
+ * content; fully drained, drainMs is 0 and playedMs has reached the scheduled total). */
7200
+ anchorSubturn() {
7201
+ this.wordAnchorPending = false;
7202
+ this.wordOrigin = Math.max(0, this.player.playedMs() + this.player.drainMs()) / 1e3;
7203
+ this.diag("reveal_anchor", { originSec: Number(this.wordOrigin.toFixed(3)), held: this.wordPending.length });
7204
+ for (const [w, s] of this.wordPending) {
7205
+ this.wordStarts.push(this.wordOrigin + s);
7206
+ this.wordTexts.push(w);
7207
+ }
7208
+ this.wordPending = [];
7209
+ }
7210
+ resetSubturnAnchor() {
7211
+ this.wordOrigin = 0;
7212
+ this.wordAnchorPending = false;
7213
+ this.wordPending = [];
7214
+ }
6904
7215
  /** Karaoke reveal poll: reveal the display-word prefix up to the count of spoken words whose
6905
7216
  * Cartesia start time has been reached on the playback clock (`AudioSink.playedMs`, which excludes
6906
7217
  * paused time). Poll-driven (not AudioContext timers) so it's portable across CLI/browser sinks and
@@ -6915,8 +7226,8 @@ var VoiceEngine = class _VoiceEngine {
6915
7226
  while (n < this.wordStarts.length && this.wordStarts[n] <= playedSec) n++;
6916
7227
  if (n !== this.revealedN) {
6917
7228
  this.revealedN = n;
6918
- const words = this.revealText.trim().split(/\s+/).filter(Boolean);
6919
- this.options.onReveal(words.slice(0, n).join(" "));
7229
+ const words2 = this.revealText.trim().split(/\s+/).filter(Boolean);
7230
+ this.options.onReveal(words2.slice(0, n).join(" "));
6920
7231
  }
6921
7232
  this.revealPoll = this.clock.setTimeout(tick, 60);
6922
7233
  };
@@ -6972,6 +7283,11 @@ var VoiceEngine = class _VoiceEngine {
6972
7283
  if (this.turnStartAt) log12.debug(`turn: ${Math.round(this.clock.now() - this.turnStartAt)}ms (incl. playback)`);
6973
7284
  this.echoUntil = this.clock.now() + 2500;
6974
7285
  if (!this.usingAec) this.stt.reset();
7286
+ try {
7287
+ this.options.onSpeechEnd();
7288
+ } catch (e) {
7289
+ log12.warn(`onSpeechEnd threw: ${e instanceof Error ? e.message : e}`);
7290
+ }
6975
7291
  this.setState("listening");
6976
7292
  if (this.uttQueue.length) this.pumpQueue();
6977
7293
  };
@@ -6986,8 +7302,9 @@ var VoiceEngine = class _VoiceEngine {
6986
7302
  this.drainTimer = this.clock.setTimeout(drainThenSettle, 15e3);
6987
7303
  } else drainThenSettle();
6988
7304
  }
6989
- /** text of the reply cut by the last barge-in — consumed by the host to tell the model what
6990
- * the user did NOT hear. Cleared on read. */
7305
+ /** What the last barge-in cut — the reply that was playing (and its estimated heard part) plus every queued worker
7306
+ * utterance it dropped — consumed by the host to tell the model what the user did NOT hear, or to park the rest of
7307
+ * a delivery for resuming. Cleared on read. */
6991
7308
  takeInterruptedReply() {
6992
7309
  const r = this.lastInterrupted;
6993
7310
  this.lastInterrupted = null;
@@ -6996,11 +7313,12 @@ var VoiceEngine = class _VoiceEngine {
6996
7313
  /** Speak a short filler phrase without starting a model turn (stays in listening mode after). */
6997
7314
  speakFiller(text) {
6998
7315
  if (!text || this.speaking) return;
6999
- const replied = this.repliedSinceDispatch;
7316
+ const replied = this.repliedSinceDispatch, reply = this.dispatchReply;
7000
7317
  this.beginSpeech();
7001
7318
  this.speakDelta(text);
7002
7319
  this.endSpeech();
7003
7320
  this.repliedSinceDispatch = replied;
7321
+ this.dispatchReply = reply;
7004
7322
  }
7005
7323
  /** Enqueue a COMPLETE worker utterance (already-split spoken text) onto the central speech queue.
7006
7324
  * If nothing is currently speaking it plays immediately; otherwise it queues and plays after the
@@ -7018,6 +7336,7 @@ var VoiceEngine = class _VoiceEngine {
7018
7336
  const text = this.uttQueue.shift();
7019
7337
  if (text == null) return;
7020
7338
  this.beginSpeech();
7339
+ this.queuedTurn = true;
7021
7340
  this.speakDelta(text);
7022
7341
  this.endSpeech();
7023
7342
  }
@@ -7032,6 +7351,69 @@ var VoiceEngine = class _VoiceEngine {
7032
7351
  * micro-ack already spoke one. Anchored end-to-end so only a whole ack-only clause matches. */
7033
7352
  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))[ ,]*)+$/;
7034
7353
  recentAcks = [];
7354
+ /** The armed turn's ack choice: the host's `pickAck` (sync or async); `phrase` undefined = the default pools, drawn
7355
+ * only when the ack actually fires. `onDone` is set only while the ack is due and the pick is still pending. */
7356
+ ackPick = null;
7357
+ startAckPick() {
7358
+ const host = this.options.pickAck;
7359
+ const pick = { pending: false, phrase: void 0, picked: !!host, onDone: null };
7360
+ if (!host) return pick;
7361
+ const ctx = { text: this.lastDispatchText, question: this.lastDispatchWasQuestion, barged: this.bargedBeforeDispatch };
7362
+ const fallback = (e) => {
7363
+ log12.warn(`pickAck failed \u2014 using the default pools: ${e instanceof Error ? e.message : e}`);
7364
+ pick.picked = false;
7365
+ return void 0;
7366
+ };
7367
+ let r;
7368
+ try {
7369
+ r = host(ctx);
7370
+ } catch (e) {
7371
+ pick.phrase = fallback(e);
7372
+ return pick;
7373
+ }
7374
+ if (typeof r?.then !== "function") {
7375
+ pick.phrase = r;
7376
+ return pick;
7377
+ }
7378
+ pick.pending = true;
7379
+ Promise.resolve(r).catch(fallback).then((phrase) => {
7380
+ pick.pending = false;
7381
+ pick.phrase = phrase;
7382
+ if (this.ackPick === pick) pick.onDone?.();
7383
+ });
7384
+ return pick;
7385
+ }
7386
+ /** Speak the adaptive ack (null = the host chose silence, undefined = draw from the default pools) — only if the
7387
+ * turn is still waiting on its reflex. */
7388
+ fireAck(phrase, picked) {
7389
+ this.ackPick = null;
7390
+ if (!this.speaking || !this.ctxOpen || this.spokeDeltas || this.interrupted) return;
7391
+ if (phrase === void 0) phrase = this.pickAck();
7392
+ if (phrase === null || !phrase.trim()) {
7393
+ this.diag("ack_skipped", { reason: "silent" });
7394
+ return;
7395
+ }
7396
+ this.diag("ack_fired", { phrase, adaptive: true, waitedMs: this.options.adaptiveAckMs, ...picked ? { picked } : {} });
7397
+ this.tts.speak(phrase + " ", true);
7398
+ this.spokeDeltas = true;
7399
+ this.ackAt = this.clock.now();
7400
+ this.lastAck = phrase;
7401
+ this.ackSpent = true;
7402
+ this.ackWords = phrase.trim().split(/\s+/).length;
7403
+ for (const w of this.words(phrase)) this.echoWords.add(w);
7404
+ this.setState("speaking");
7405
+ this.adaptiveAckFired = true;
7406
+ this.ackProbe = "";
7407
+ this.ackPickedFired = picked;
7408
+ this.options.onAdaptiveAck();
7409
+ }
7410
+ /** `phrase`'s words at the very start of a text, any punctuation between them ("Oh no." ~ "oh, no —"). */
7411
+ static leadingPhrase(phrase) {
7412
+ const w = phrase.toLowerCase().match(/[a-z0-9]+/g) ?? [];
7413
+ return w.length ? new RegExp(`^[^a-z0-9]*${w.join("[^a-z0-9]+")}(?![a-z0-9])[^a-z0-9]*`, "i") : /$^/;
7414
+ }
7415
+ ackPickedFired = false;
7416
+ // the fired ack was the host's pick → the probe also strips the reflex repeating it
7035
7417
  pickAck() {
7036
7418
  const pool = this.lastDispatchWasQuestion ? _VoiceEngine.ACKS_QUESTION : _VoiceEngine.ACKS_NEUTRAL;
7037
7419
  const fresh = pool.filter((p) => !this.recentAcks.includes(p));
@@ -7041,7 +7423,16 @@ var VoiceEngine = class _VoiceEngine {
7041
7423
  return phrase;
7042
7424
  }
7043
7425
  lastDispatchWasQuestion = false;
7426
+ dispatchRefinal = false;
7427
+ // the last dispatch re-finalized (superseded) the one before it — same logical turn
7428
+ ackSpent = false;
7429
+ // an ack already played for the current logical turn (reset by a non-refinal dispatch)
7430
+ bargedSinceDispatch = false;
7431
+ // an effective interrupt landed since the last dispatch
7432
+ bargedBeforeDispatch = false;
7433
+ // …as of the last dispatch (AckContext.barged)
7044
7434
  clearAckTimer(reason) {
7435
+ if (reason) this.ackPick = null;
7045
7436
  if (!this.ackTimer) return;
7046
7437
  this.clock.clearTimeout(this.ackTimer);
7047
7438
  this.ackTimer = null;
@@ -7057,11 +7448,16 @@ var VoiceEngine = class _VoiceEngine {
7057
7448
  this.clearAckTimer("interrupt");
7058
7449
  this.adaptiveAckFired = false;
7059
7450
  this.ackProbe = null;
7060
- if (this.uttQueue.length) log12.info(`barge-in dropped ${this.uttQueue.length} queued worker utterance(s)`);
7061
- const droppedQueued = this.uttQueue.length;
7062
- this.uttQueue = [];
7063
- if (!this.speaking && !this.drainTimer) return;
7451
+ const queued = this.uttQueue.splice(0);
7452
+ const droppedQueued = queued.length;
7453
+ if (droppedQueued) log12.info(`barge-in cut ${droppedQueued} queued worker utterance(s) \u2014 handed to the host`);
7454
+ this.overlapKeptUntil = 0;
7455
+ if (!this.speaking && !this.drainTimer) {
7456
+ if (droppedQueued) this.lastInterrupted = { full: "", heard: "", queued, queuedTurn: false };
7457
+ return;
7458
+ }
7064
7459
  this.stopWordReveal(false);
7460
+ this.resetSubturnAnchor();
7065
7461
  this.diag("interrupt", { droppedQueued, ctxOpen: this.ctxOpen, playedMs: Math.round(Math.max(0, this.player.playedMs())) });
7066
7462
  if (this.drainTimer) {
7067
7463
  this.clock.clearTimeout(this.drainTimer);
@@ -7069,8 +7465,9 @@ var VoiceEngine = class _VoiceEngine {
7069
7465
  }
7070
7466
  this.resetOverlap(false);
7071
7467
  this.lastResumeAt = 0;
7072
- const heardChars = Math.round(Math.max(0, this.player.playedMs()) / 1e3 * 15);
7073
- if (this.reply) this.lastInterrupted = { full: this.reply, heard: this.reply.slice(0, heardChars) };
7468
+ const { heard, exact } = this.heardCut();
7469
+ if (this.reply || droppedQueued) this.lastInterrupted = { full: this.reply, heard, exact, queued, queuedTurn: this.queuedTurn };
7470
+ if (heard.length < this.reply.length || droppedQueued) this.bargedSinceDispatch = true;
7074
7471
  this.speaking = false;
7075
7472
  this.ctxOpen = false;
7076
7473
  this.interrupted = true;
@@ -7082,8 +7479,23 @@ var VoiceEngine = class _VoiceEngine {
7082
7479
  if (this.reply) this.prevReply = this.reply;
7083
7480
  this.setState("listening");
7084
7481
  }
7482
+ /** How much of the reply the user heard at a cut (paused time is excluded by the sink's playedMs). Exact from the
7483
+ * TTS word timestamps the playback clock has reached (the ack's leading words skipped); without timestamps, an
7484
+ * estimate — played seconds × ~15 chars/s of TTS speech, coarse and often mid-word. */
7485
+ heardCut() {
7486
+ const played = Math.max(0, this.player.playedMs());
7487
+ if (!this.wordTexts.length) return { heard: this.reply.slice(0, Math.round(played / 1e3 * 15)), exact: false };
7488
+ let n = 0;
7489
+ while (n < this.wordStarts.length && this.wordStarts[n] <= played / 1e3) n++;
7490
+ if (!this.ctxOpen && n === this.wordStarts.length && played / 1e3 - this.wordStarts[n - 1] >= 0.4) return { heard: this.reply, exact: true };
7491
+ return { heard: this.reply.slice(0, heardPrefix(this.reply, this.wordTexts.slice(this.ackWords, n))), exact: true };
7492
+ }
7085
7493
  stop() {
7086
7494
  this.uttQueue = [];
7495
+ if (this.decideTimer) {
7496
+ this.clock.clearTimeout(this.decideTimer);
7497
+ this.decideTimer = null;
7498
+ }
7087
7499
  this.clearAckTimer();
7088
7500
  if (this.resumeTimer) this.clock.clearTimeout(this.resumeTimer);
7089
7501
  if (this.pendingTimer) this.clock.clearTimeout(this.pendingTimer);
@@ -7096,9 +7508,41 @@ var VoiceEngine = class _VoiceEngine {
7096
7508
  this.setState("idle");
7097
7509
  }
7098
7510
  // --- listening side (STT-driven) ---
7511
+ /** Echo-comparable tokens. Our text and the STT's rendering of it must tokenize alike: every non-alphanumeric
7512
+ * (em-dash, non-breaking hyphen, nbsp, …) SEPARATES words — deleting it glued "releases—GPT‑6" into
7513
+ * "releasesgpt6" and the echo gate missed our own words. Numbers take the spoken form STT may hear: letter/digit
7514
+ * runs split ("K2.8" → k 2 point 8), a decimal point is "point", number words are digits ("five" → 5).
7515
+ * Apostrophes are dropped (it's → its). Words under 2 letters are noise; digits are kept. Any script counts (Unicode
7516
+ * letters/marks/digits): an ASCII-only split gave Hebrew speech ZERO words, which the echo gate read as pure echo
7517
+ * (live 20260923-125143: "סבתא מחוץ לדלת?" and "תודה." swallowed). */
7099
7518
  words(s) {
7100
- return s.toLowerCase().replace(/[^a-z0-9\s]/g, "").split(/\s+/).filter((w) => w.length >= 2);
7101
- }
7519
+ 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));
7520
+ }
7521
+ static NUMBER_WORDS = Object.fromEntries(
7522
+ [
7523
+ "zero",
7524
+ "one",
7525
+ "two",
7526
+ "three",
7527
+ "four",
7528
+ "five",
7529
+ "six",
7530
+ "seven",
7531
+ "eight",
7532
+ "nine",
7533
+ "ten",
7534
+ "eleven",
7535
+ "twelve",
7536
+ "thirteen",
7537
+ "fourteen",
7538
+ "fifteen",
7539
+ "sixteen",
7540
+ "seventeen",
7541
+ "eighteen",
7542
+ "nineteen",
7543
+ "twenty"
7544
+ ].map((w, i) => [w, String(i)])
7545
+ );
7102
7546
  novelWords(text) {
7103
7547
  return this.words(text).filter((w) => !this.echoWords.has(w));
7104
7548
  }
@@ -7115,6 +7559,22 @@ var VoiceEngine = class _VoiceEngine {
7115
7559
  const novel = this.novelWords(text).length;
7116
7560
  return novel > 0 && novel / Math.max(1, total) > 0.5;
7117
7561
  }
7562
+ /** Echo verdict for a FINAL in the echo window (AEC tier). Bag-of-words genuine() alone swallowed real replies
7563
+ * that reuse a few of our words — live 20260923-102258, right after a barge over "…Gemini 3.8 Flash…":
7564
+ * "Wait, there is a new Gemini model?" (3 novel / 6) was dropped twice and the user had to repeat it. Echo
7565
+ * of our own audio reproduces RUNS of what we said, in order (mis-hearings break a word, not the run); a
7566
+ * reply that shares vocabulary scatters it. So a ≥4-word final that fails the bag test is echo only if at
7567
+ * least half its words sit in a word pair we actually said. Shorter finals can't show order → bag rule. */
7568
+ echoFinal(text) {
7569
+ if (this.genuine(text)) return false;
7570
+ const w = this.words(text);
7571
+ if (w.length < 4) return true;
7572
+ const said = this.words(`${this.prevReply} ${this.reply} ${this.lastAck}`);
7573
+ const pairs = new Set(said.slice(1).map((x, i) => `${said[i]} ${x}`));
7574
+ const inRun = w.map(() => false);
7575
+ for (let i = 1; i < w.length; i++) if (pairs.has(`${w[i - 1]} ${w[i]}`)) inRun[i - 1] = inRun[i] = true;
7576
+ return inRun.filter(Boolean).length * 2 >= w.length;
7577
+ }
7118
7578
  handlePartial(text) {
7119
7579
  if (this.speaking) {
7120
7580
  if (!this.options.bargeIn) return;
@@ -7126,14 +7586,29 @@ var VoiceEngine = class _VoiceEngine {
7126
7586
  if (!this.echoActive() || (this.usingAec ? this.genuine(text) : this.novelWords(text).length >= 1)) this.options.onPartial(text);
7127
7587
  return;
7128
7588
  }
7589
+ if (this.echoesDispatch(text, true)) {
7590
+ if (this.lastDupPartial !== text.trim()) {
7591
+ this.lastDupPartial = text.trim();
7592
+ this.diag("dup_partial", { reason: "dup_final", text: text.slice(0, 60) });
7593
+ }
7594
+ if (this.pausedAt) {
7595
+ this.diag("overlap_resume", { heldMs: Math.round(this.clock.now() - this.pausedAt), reason: "dup_final" });
7596
+ this.resetOverlap(true);
7597
+ }
7598
+ return;
7599
+ }
7129
7600
  if (this.overlapCapable) {
7130
7601
  const txt = text.trim();
7131
7602
  if (!txt || txt === this.lastOverlapPartial) return;
7132
7603
  this.lastOverlapPartial = txt;
7133
7604
  if (!this.genuine(txt)) {
7134
- if (this.pausedAt) this.armResume();
7605
+ if (this.pausedAt && !this.decideTimer) this.armResume();
7135
7606
  return;
7136
7607
  }
7608
+ if (this.overlapKeptUntil && this.clock.now() < this.overlapKeptUntil) {
7609
+ if (!this.unjudged(txt)) return;
7610
+ this.overlapKeptUntil = 0;
7611
+ }
7137
7612
  if (!this.pausedAt) {
7138
7613
  this.pausedAt = this.clock.now();
7139
7614
  const sinceResumeMs = this.lastResumeAt ? Math.round(this.clock.now() - this.lastResumeAt) : void 0;
@@ -7148,13 +7623,14 @@ var VoiceEngine = class _VoiceEngine {
7148
7623
  }
7149
7624
  }
7150
7625
  if (this.words(txt).length >= 2) {
7151
- const phase = this.ctxOpen ? "speaking" : "drain";
7152
- this.diag("barge_in", { phase, trigger: "overlap_cede", novel: this.novelWords(txt).length, total: this.words(txt).length });
7153
- this.interrupt();
7154
- this.options.onBargeIn(phase);
7626
+ if (this.options.overlapDecideMs > 0 && this.shortOverlap(txt)) {
7627
+ this.awaitOverlapDecision(txt);
7628
+ return;
7629
+ }
7630
+ this.cedeOverlap("overlap_cede", txt);
7155
7631
  return;
7156
7632
  }
7157
- this.armResume();
7633
+ if (!this.decideTimer) this.armResume();
7158
7634
  return;
7159
7635
  }
7160
7636
  const barge = this.usingAec ? this.genuine(text) : this.novelWords(text).length >= (this.suspectUntil ? 1 : 2);
@@ -7199,7 +7675,7 @@ var VoiceEngine = class _VoiceEngine {
7199
7675
  this.specTimer = null;
7200
7676
  if (this.speaking || this.specSpent || this.pendingUtt) return;
7201
7677
  if (this.words(this.specPartial).length < this.options.speculativeMinWords) return;
7202
- const flat = this.specPartial.toLowerCase().replace(/[^a-z0-9]/g, "");
7678
+ const flat = normWord(this.specPartial);
7203
7679
  if (flat && this.lastDispatchFlat && this.clock.now() - this.lastDispatchAt < _VoiceEngine.DUP_FINAL_MS && (this.lastDispatchFlat.startsWith(flat) || flat.startsWith(this.lastDispatchFlat))) return;
7204
7680
  this.specSpent = true;
7205
7681
  this.specText = this.specPartial;
@@ -7238,7 +7714,7 @@ var VoiceEngine = class _VoiceEngine {
7238
7714
  const letters = p.replace(/[^\p{L}]/gu, "");
7239
7715
  const latin = p.replace(/[^A-Za-z]/g, "");
7240
7716
  if (!letters || latin.length / letters.length < 0.7) return veto("non_latin");
7241
- const flat = p.toLowerCase().replace(/[^a-z0-9]/g, "");
7717
+ const flat = normWord(p);
7242
7718
  if (flat && this.lastDispatchFlat && this.clock.now() - this.lastDispatchAt < _VoiceEngine.DUP_FINAL_MS && (this.lastDispatchFlat.startsWith(flat) || flat.startsWith(this.lastDispatchFlat))) return;
7243
7719
  this.fireBackchannel();
7244
7720
  }, this.options.backchannelMs);
@@ -7326,6 +7802,10 @@ var VoiceEngine = class _VoiceEngine {
7326
7802
  }
7327
7803
  if (pfx && norm2(nw[pw.length - 1]).startsWith(norm2(pw[pw.length - 1]))) return next;
7328
7804
  }
7805
+ if (_VoiceEngine.restates(prev, next)) {
7806
+ this.diag("merge_restated", { dropped: prev.slice(0, 60) });
7807
+ return next;
7808
+ }
7329
7809
  const max = Math.min(pw.length, nw.length);
7330
7810
  for (let k = max; k > 0; k--) {
7331
7811
  let match = true;
@@ -7340,6 +7820,25 @@ var VoiceEngine = class _VoiceEngine {
7340
7820
  static normWord(w) {
7341
7821
  return normWord(w);
7342
7822
  }
7823
+ /** `next` RESTATES the held, unfinished `prev` — the user restarted ("Wo- wait, can you explain more about" →
7824
+ * "What— wait, can you explain more about that? …", live 20260924-093945, where both landed in the dispatch).
7825
+ * Keep only the later version. Conservative: prev's words from index i ≤ 2 through its END reappear contiguously
7826
+ * 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
7827
+ * an overlapping re-final continuing prev ("Tell me about the" + "about the weather"), deduped by the caller
7828
+ * keeping prev's head. prev's last word may be a char-prefix (a mid-word endpoint). */
7829
+ static restates(prev, next) {
7830
+ const p = prev.split(/\s+/).map(normWord).filter(Boolean), n = next.split(/\s+/).map(normWord).filter(Boolean);
7831
+ for (let i = 0; i <= 2; i++) {
7832
+ const k = p.length - i;
7833
+ if (k < 4 || k < 0.75 * p.length) return false;
7834
+ for (let j = i === 0 ? 0 : 1; j <= 2 && j + k <= n.length; j++) {
7835
+ let ok = true;
7836
+ 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];
7837
+ if (ok) return true;
7838
+ }
7839
+ }
7840
+ return false;
7841
+ }
7343
7842
  /** Soniox re-finalization of an ALREADY-DISPATCHED utterance, arriving PAST the merge window:
7344
7843
  * the new final is a strict word-prefix superset of the last dispatch (the last dispatched word
7345
7844
  * may be a char-prefix of the corresponding new word — a mid-word endpoint), and it lands within
@@ -7370,18 +7869,71 @@ var VoiceEngine = class _VoiceEngine {
7370
7869
  if (!nSame.startsWith(pLast)) return false;
7371
7870
  return nw.length > pw.length || nSame.length > pLast.length;
7372
7871
  }
7872
+ /** The STT re-emitting (part of) the utterance just dispatched: ≥2 words that form a contiguous run of the dispatched
7873
+ * words, within DUP_FINAL_MS. A merged dispatch ("Okay." + "Um, check for the recent news in AI.") has its later
7874
+ * segment come back as a partial/final a beat later — the identical/superset guards miss a PIECE. A human cannot
7875
+ * re-speak a ≥2-word stretch of what they said within ~1s; one word ("stop") is never treated as a duplicate.
7876
+ * `partial`: the last word may be cut mid-word ("…news in A"). */
7877
+ echoesDispatch(text, partial = false) {
7878
+ if (!this.lastDispatchWords.length || this.clock.now() - this.lastDispatchAt >= _VoiceEngine.DUP_FINAL_MS) return false;
7879
+ const d = this.lastDispatchWords.map(normWord).filter(Boolean), w = text.trim().split(/\s+/).map(normWord).filter(Boolean);
7880
+ if (w.length < 2 || w.length > d.length) return false;
7881
+ const last = w.length - 1;
7882
+ outer: for (let i = 0; i + w.length <= d.length; i++) {
7883
+ 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;
7884
+ return true;
7885
+ }
7886
+ return false;
7887
+ }
7888
+ /** The agent replied since the dispatch with more than an acknowledgement (ACK_LEADING: "On it.", "Sure, one sec."). */
7889
+ answeredSinceDispatch() {
7890
+ return this.repliedSinceDispatch && !_VoiceEngine.ACK_LEADING.test(this.dispatchReply.replace(/[^a-z ]/gi, " ").replace(/\s+/g, " ").trim().toLowerCase());
7891
+ }
7892
+ lastDupPartial = "";
7893
+ // dup_partial diag once per distinct partial
7373
7894
  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;
7895
+ /** The user's line right after a barge cut our speech (or a re-final growing it), with no terminal punctuation:
7896
+ * they took the floor mid-thought — Soniox closes on the pause, not the thought (live 20260923-192543: "But isn't it"
7897
+ * → "…can be done" → "…with boxing" → "…or semantic?" dispatched 4 times). Held like an incomplete utterance. */
7898
+ unfinishedBargeLine(text) {
7899
+ const line = this.bargedSinceDispatch || this.bargedBeforeDispatch && this.refinalizes(text);
7900
+ return line && !/[.?!…]["'”’)\]]*$/.test(text.trim()) && !_VoiceEngine.CONTROL_RE.test(text.trim());
7901
+ }
7902
+ /** A short line that is complete on its own ("Stop", "Yes", "Okay, stop", "go on") — never held as unfinished. */
7903
+ 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)");
7374
7904
  /** The utterance sounds like the user paused mid-thought (trailing conjunction/filler/comma). */
7375
7905
  looksIncomplete(text) {
7376
- return _VoiceEngine.TRAIL_RE.test(text.trim());
7906
+ return _VoiceEngine.TRAIL_RE.test(text.trim()) && !_VoiceEngine.CONTROL_RE.test(text.trim());
7377
7907
  }
7378
7908
  handleUtterance(text) {
7379
- if (this.speaking && (this.ctxOpen || this.pausedAt) && this.overlapCapable) {
7909
+ if (this.speaking && this.echoesDispatch(text)) {
7910
+ this.diag("utterance_drop", { reason: "dup_final", text: text.slice(0, 60) });
7911
+ if (this.pausedAt) {
7912
+ this.diag("overlap_resume", { heldMs: Math.round(this.clock.now() - this.pausedAt), reason: "dup_final" });
7913
+ this.resetOverlap(true);
7914
+ }
7915
+ return;
7916
+ }
7917
+ if (this.decideTimer) {
7918
+ this.decideHeldFinal = this.mergeUtterance(this.decideHeldFinal, text);
7919
+ this.diag("utterance_held", { reason: "overlap_decide", text: text.slice(0, 60) });
7920
+ return;
7921
+ }
7922
+ if (this.overlapKeptUntil) {
7923
+ const kept = this.clock.now() < this.overlapKeptUntil && !this.unjudged(text);
7924
+ this.overlapKeptUntil = 0;
7925
+ if (kept) {
7926
+ this.diag("utterance_drop", { reason: "overlap_kept", text: text.slice(0, 60) });
7927
+ return;
7928
+ }
7929
+ }
7930
+ const ownTail = !this.pausedAt && (this.clock.now() < this.bargeGraceUntil || this.refinalizes(text));
7931
+ if (this.speaking && (this.ctxOpen || this.pausedAt) && this.overlapCapable && !ownTail) {
7380
7932
  this.diag("utterance_drop", { reason: "overlap_noise", text: text.slice(0, 60) });
7381
7933
  this.stt.reset();
7382
7934
  return;
7383
7935
  }
7384
- if (this.echoActive() && (!this.options.bargeIn || (this.usingAec ? !this.genuine(text) : this.novelWords(text).length < 2))) {
7936
+ if (this.echoActive() && (!this.options.bargeIn || (this.usingAec ? this.echoFinal(text) : this.novelWords(text).length < 2))) {
7385
7937
  this.diag("echo_swallow", { guard: "echo_gate", text: text.slice(0, 60) });
7386
7938
  this.stt.reset();
7387
7939
  return;
@@ -7395,9 +7947,28 @@ var VoiceEngine = class _VoiceEngine {
7395
7947
  if (this.pendingUtt) this.mergePath = "merged";
7396
7948
  this.pendingUtt = this.mergeUtterance(this.pendingUtt, text);
7397
7949
  if (this.pendingTimer) this.clock.clearTimeout(this.pendingTimer);
7398
- if (this.options.incompleteMergeMs && this.looksIncomplete(this.pendingUtt)) {
7950
+ if (this.options.adaptiveEndpointing) {
7951
+ const holdMs = computeHoldMs({
7952
+ transcript: this.pendingUtt,
7953
+ lastAiMessage: this.prevReply,
7954
+ baseMs: this.options.utteranceMergeMs,
7955
+ incompleteMs: this.options.incompleteMergeMs
7956
+ });
7957
+ const tier = endpointTier(this.pendingUtt);
7958
+ this.holdTier = tier;
7959
+ this.diag("hold", { reason: "adaptive", tier, punctuated: tier === 3, holdMs, tail: this.pendingUtt.slice(-40) });
7960
+ if (holdMs > this.options.utteranceMergeMs) {
7961
+ this.options.onHold();
7962
+ if (tier === 1 && this.options.holdFiller && !this.speaking) this.speakFiller(this.options.holdFiller);
7963
+ }
7964
+ if (holdMs <= 0) return this.flushUtterance();
7965
+ this.pendingTimer = this.clock.setTimeout(() => this.flushUtterance(), holdMs);
7966
+ return;
7967
+ }
7968
+ const bargeHold = !this.looksIncomplete(this.pendingUtt) && this.unfinishedBargeLine(this.pendingUtt);
7969
+ if (this.options.incompleteMergeMs && (bargeHold || this.looksIncomplete(this.pendingUtt))) {
7399
7970
  log12.verbose(`hold: incomplete utterance "${this.pendingUtt.slice(-40)}"`);
7400
- this.diag("hold", { reason: "incomplete", tail: this.pendingUtt.slice(-40) });
7971
+ this.diag("hold", { reason: bargeHold ? "barge_unfinished" : "incomplete", tail: this.pendingUtt.slice(-40) });
7401
7972
  this.options.onHold();
7402
7973
  if (this.options.holdFiller && !this.speaking) this.speakFiller(this.options.holdFiller);
7403
7974
  this.pendingTimer = this.clock.setTimeout(() => this.flushUtterance(), this.options.incompleteMergeMs);
@@ -7428,9 +7999,13 @@ var VoiceEngine = class _VoiceEngine {
7428
7999
  this.pendingUtt = "";
7429
8000
  const path = this.mergePath;
7430
8001
  this.mergePath = "direct";
8002
+ const heldTier = this.holdTier;
8003
+ this.holdTier = 0;
7431
8004
  if (text) {
7432
- const flat = text.toLowerCase().replace(/[^a-z0-9]/g, "");
7433
- if (flat && flat === this.lastDispatchFlat && !this.repliedSinceDispatch && this.clock.now() - this.lastDispatchAt < _VoiceEngine.DUP_FINAL_MS) {
8005
+ const flat = normWord(text);
8006
+ const answered = this.answeredSinceDispatch();
8007
+ const part = flat !== this.lastDispatchFlat && !answered && this.echoesDispatch(text);
8008
+ if (part || flat && flat === this.lastDispatchFlat && !answered && this.clock.now() - this.lastDispatchAt < _VoiceEngine.DUP_FINAL_MS) {
7434
8009
  log12.verbose(`dropped duplicate final "${text.slice(0, 40)}" (identical to the just-dispatched utterance, no reply between)`);
7435
8010
  this.diag("utterance_drop", { reason: "dup_final", text: text.slice(0, 60) });
7436
8011
  return;
@@ -7441,22 +8016,121 @@ var VoiceEngine = class _VoiceEngine {
7441
8016
  this.diag("utterance_drop", { reason: "refinalized", text: text.slice(0, 60) });
7442
8017
  this.lastDispatchFlat = flat;
7443
8018
  this.lastDispatchWords = text.trim().split(/\s+/);
8019
+ this.lastDispatchText = text;
7444
8020
  return;
7445
8021
  }
8022
+ const meta = refinal ? { supersedes: this.lastDispatchText } : void 0;
7446
8023
  this.lastDispatchFlat = flat;
7447
8024
  this.lastDispatchWords = text.trim().split(/\s+/);
8025
+ this.lastDispatchText = text;
7448
8026
  this.lastDispatchAt = this.clock.now();
7449
8027
  this.repliedSinceDispatch = false;
8028
+ this.dispatchReply = "";
7450
8029
  this.lastDispatchWasQuestion = /\?\s*$/.test(text);
8030
+ this.bargedBeforeDispatch = refinal ? this.bargedBeforeDispatch || this.bargedSinceDispatch : this.bargedSinceDispatch;
8031
+ this.bargedSinceDispatch = false;
8032
+ this.dispatchRefinal = refinal;
8033
+ if (!refinal) this.ackSpent = false;
7451
8034
  this.turnStartAt = this.clock.now();
7452
8035
  this.bargeGraceUntil = this.clock.now() + this.options.bargeGraceMs;
7453
- this.diag("dispatch", { text, path: refinal ? "refinalized" : path, question: this.lastDispatchWasQuestion });
7454
- this.options.onUtterance(text);
8036
+ this.diag("dispatch", { text, path: refinal ? "refinalized" : path, heldTier, question: this.lastDispatchWasQuestion, ...this.bargedBeforeDispatch ? { barged: true } : {} });
8037
+ this.options.onUtterance(text, meta);
7455
8038
  }
7456
8039
  }
7457
8040
  get overlapCapable() {
7458
8041
  return this.usingAec && this.options.overlapPause && !!this.player.pause && !!this.player.resume;
7459
8042
  }
8043
+ /** Stop/question words that make an overlap an obvious barge — never held for a host decision. */
8044
+ static OVERLAP_CEDE_WORDS = /* @__PURE__ */ new Set([
8045
+ "stop",
8046
+ "wait",
8047
+ "no",
8048
+ "hold",
8049
+ "hang",
8050
+ "pause",
8051
+ "quiet",
8052
+ "enough",
8053
+ "shut",
8054
+ "cancel",
8055
+ "what",
8056
+ "why",
8057
+ "how",
8058
+ "who",
8059
+ "when",
8060
+ "where",
8061
+ "which"
8062
+ ]);
8063
+ /** A short, non-urgent overlap: the kind a listener cue or reaction takes (see overlapDecideMs). */
8064
+ shortOverlap(text) {
8065
+ const w = this.words(text);
8066
+ return w.length <= this.options.overlapDecideMaxWords && !/\?/.test(text) && !w.some((x) => _VoiceEngine.OVERLAP_CEDE_WORDS.has(x));
8067
+ }
8068
+ /** Cede the floor to an overlap: interrupt, tell the host, then dispatch a final that was held while deciding. */
8069
+ cedeOverlap(trigger, txt) {
8070
+ const phase = this.ctxOpen ? "speaking" : "drain";
8071
+ const held2 = this.decideHeldFinal;
8072
+ this.decideHeldFinal = "";
8073
+ this.diag("barge_in", { phase, trigger, novel: this.novelWords(txt).length, total: this.words(txt).length });
8074
+ this.interrupt();
8075
+ this.options.onBargeIn(phase);
8076
+ if (held2) this.handleUtterance(held2);
8077
+ }
8078
+ /** Wait (bounded) for the host's verdict. `fresh` = a re-decision on grown text: it gets its own window — the host
8079
+ * was only just asked about it (live: "Oh," → "Oh, that" → "Oh, that— that's new" outgrew every verdict, and the
8080
+ * first window ran out mid-chase, ceding a reaction Jev kept) — but the whole hold is capped at 3× the window. */
8081
+ awaitOverlapDecision(txt, fresh = false) {
8082
+ if (this.decideTimer && !fresh) return;
8083
+ if (this.resumeTimer) {
8084
+ this.clock.clearTimeout(this.resumeTimer);
8085
+ this.resumeTimer = null;
8086
+ }
8087
+ if (this.decideTimer) this.clock.clearTimeout(this.decideTimer);
8088
+ const now4 = this.clock.now(), ms = this.options.overlapDecideMs;
8089
+ if (!this.decideSince) this.decideSince = now4;
8090
+ const wait = Math.min(ms, this.decideSince + 3 * ms - now4);
8091
+ this.diag("overlap_await", { text: txt.slice(0, 60), ms: wait });
8092
+ this.decideTimer = this.clock.setTimeout(() => {
8093
+ this.decideTimer = null;
8094
+ if (this.pausedAt) this.cedeOverlap("overlap_decide_timeout", this.lastOverlapPartial || txt);
8095
+ }, Math.max(0, wait));
8096
+ }
8097
+ /** Words in `text` the host's keep_talking verdict never covered (the overlap grew after it judged). */
8098
+ unjudged(text) {
8099
+ const seen = new Set(this.words(this.overlapKept));
8100
+ return this.words(text).some((w) => !seen.has(w));
8101
+ }
8102
+ /** The host's verdict on the overlap the engine is holding (overlapDecideMs, or any live overlap pause):
8103
+ * 'yield' cedes the floor (a final that landed meanwhile is dispatched); 'keep_talking' resumes playback and
8104
+ * absorbs the rest of THAT reaction — `heard`, the text the verdict judged (default: the live partial); its final
8105
+ * is dropped. Words beyond `heard` are a new overlap, decided afresh: if the partial/final has already grown past
8106
+ * it, the hold stays and `overlap_pause` is re-emitted for a new verdict (bounded by overlapDecideMs, else it
8107
+ * 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). */
8108
+ decideOverlap(choice, heard) {
8109
+ if (!this.pausedAt || !this.speaking) return false;
8110
+ if (choice === "yield") {
8111
+ this.cedeOverlap("host_yield", this.lastOverlapPartial);
8112
+ return true;
8113
+ }
8114
+ this.overlapKept = heard?.trim() || this.lastOverlapPartial;
8115
+ const grown = this.decideHeldFinal || this.lastOverlapPartial;
8116
+ if (this.unjudged(grown)) {
8117
+ if (!(this.options.overlapDecideMs > 0 && this.shortOverlap(grown))) {
8118
+ this.cedeOverlap("overlap_cede", grown);
8119
+ return false;
8120
+ }
8121
+ this.diag("overlap_pause", { text: grown.slice(0, 60), redecide: true });
8122
+ this.awaitOverlapDecision(grown, true);
8123
+ return false;
8124
+ }
8125
+ const held2 = this.decideHeldFinal;
8126
+ this.decideHeldFinal = "";
8127
+ this.diag("overlap_resume", { heldMs: Math.round(this.clock.now() - this.pausedAt), reason: "host_keep" });
8128
+ if (held2) this.diag("utterance_drop", { reason: "overlap_kept", text: held2.slice(0, 60) });
8129
+ this.overlapKeptUntil = held2 ? 0 : this.clock.now() + 4e3;
8130
+ this.resetOverlap(true);
8131
+ this.lastResumeAt = 0;
8132
+ return true;
8133
+ }
7460
8134
  armResume() {
7461
8135
  if (this.resumeTimer) this.clock.clearTimeout(this.resumeTimer);
7462
8136
  this.resumeTimer = this.clock.setTimeout(() => {
@@ -7472,6 +8146,20 @@ var VoiceEngine = class _VoiceEngine {
7472
8146
  this.clock.clearTimeout(this.resumeTimer);
7473
8147
  this.resumeTimer = null;
7474
8148
  }
8149
+ if (this.decideTimer) {
8150
+ this.clock.clearTimeout(this.decideTimer);
8151
+ this.decideTimer = null;
8152
+ }
8153
+ this.decideSince = 0;
8154
+ const held2 = this.decideHeldFinal;
8155
+ this.decideHeldFinal = "";
8156
+ if (held2) {
8157
+ this.diag("utterance_release", { reason: "overlap_hold_exit", text: held2.slice(0, 60) });
8158
+ this.pendingUtt = this.mergeUtterance(this.pendingUtt, held2);
8159
+ if (this.pendingTimer) this.clock.clearTimeout(this.pendingTimer);
8160
+ this.pendingTimer = this.clock.setTimeout(() => this.flushUtterance(), 0);
8161
+ }
8162
+ if (this.pausedAt) this.diag("overlap_end", { resumed: resume, heldMs: Math.round(this.clock.now() - this.pausedAt) });
7475
8163
  if (this.pausedAt && resume) {
7476
8164
  this.player.resume?.();
7477
8165
  this.lastResumeAt = this.clock.now();
@@ -7537,7 +8225,8 @@ var SentenceCoalescer = class _SentenceCoalescer {
7537
8225
  feed(delta) {
7538
8226
  if (delta) this.buf += delta;
7539
8227
  let cut = -1;
7540
- for (let i = 0; i < this.buf.length; i++) if (_SentenceCoalescer.isEnd(this.buf[i])) cut = i;
8228
+ const decimal = (i) => this.buf[i] === "." && /\d/.test(this.buf[i - 1] ?? "") && (i + 1 === this.buf.length || /\d/.test(this.buf[i + 1]));
8229
+ for (let i = 0; i < this.buf.length; i++) if (_SentenceCoalescer.isEnd(this.buf[i]) && !decimal(i)) cut = i;
7541
8230
  if (cut < 0) return "";
7542
8231
  if (this.buf[cut] !== "\n") while (cut + 1 < this.buf.length && CLOSERS.includes(this.buf[cut + 1])) cut++;
7543
8232
  const ready = this.buf.slice(0, cut + 1).trim();
@@ -7552,9 +8241,14 @@ var SentenceCoalescer = class _SentenceCoalescer {
7552
8241
  };
7553
8242
  var SpokenSplitter = class {
7554
8243
  buf = "";
7555
- inSpoken = false;
8244
+ open = false;
7556
8245
  /** True once any spoken char has ever been emitted (drives the no-spoken fallback). */
7557
8246
  spokeAny = false;
8247
+ /** Inside a `<spoken>` block after the last feed. Once it is false, a caller coalescing sentences can flush: nothing
8248
+ * more of that block is coming (a held "…in 2026." would otherwise wait for the next spoken text or the stream end). */
8249
+ get inSpoken() {
8250
+ return this.open;
8251
+ }
7558
8252
  /** Feed a delta; returns the spoken/detail spans completed by this chunk (either may be ''). */
7559
8253
  feed(delta) {
7560
8254
  this.buf += delta;
@@ -7568,26 +8262,26 @@ var SpokenSplitter = class {
7568
8262
  let spoken = "";
7569
8263
  let detail = "";
7570
8264
  while (this.buf.length) {
7571
- const tag = this.inSpoken ? CLOSE : OPEN;
8265
+ const tag = this.open ? CLOSE : OPEN;
7572
8266
  const idx = this.buf.indexOf(tag);
7573
8267
  if (idx >= 0) {
7574
8268
  const text2 = this.buf.slice(0, idx);
7575
- if (this.inSpoken) spoken += text2;
8269
+ if (this.open) spoken += text2;
7576
8270
  else detail += text2;
7577
8271
  this.buf = this.buf.slice(idx + tag.length);
7578
- this.inSpoken = !this.inSpoken;
8272
+ this.open = !this.open;
7579
8273
  continue;
7580
8274
  }
7581
8275
  const lt = this.buf.lastIndexOf("<");
7582
8276
  const holdStart = lt >= 0 && tag.startsWith(this.buf.slice(lt)) ? lt : this.buf.length;
7583
8277
  const text = this.buf.slice(0, holdStart);
7584
- if (this.inSpoken) spoken += text;
8278
+ if (this.open) spoken += text;
7585
8279
  else detail += text;
7586
8280
  this.buf = this.buf.slice(holdStart);
7587
8281
  break;
7588
8282
  }
7589
8283
  if (final && this.buf) {
7590
- if (this.inSpoken) spoken += this.buf;
8284
+ if (this.open) spoken += this.buf;
7591
8285
  else detail += this.buf;
7592
8286
  this.buf = "";
7593
8287
  }
@@ -7602,7 +8296,11 @@ var now = () => performance.now();
7602
8296
  var SonioxSTTOptions = class {
7603
8297
  auth = "";
7604
8298
  source;
7605
- model = "stt-rt-preview";
8299
+ /** `stt-rt-preview` was RETIRED (aliased to the two-generations-old v3 on 2025-10-31), so every
8300
+ * caller that never set this was silently running an old model while the alias kept it working —
8301
+ * the failure mode of a default that is a moving vendor pointer. Pin a real version instead;
8302
+ * `stt-rt-v5` is live today (verified against GET https://api.soniox.com/v1/models). */
8303
+ model = "stt-rt-v5";
7606
8304
  languageHints = ["en"];
7607
8305
  /** Client-side endpoint: finalized text + no new tokens for this long = utterance (don't wait for
7608
8306
  * Soniox's semantic <end>, which adds 0.5-1.5s — the difference between ping-pong and lag). */
@@ -7975,6 +8673,15 @@ function base64ToBytes(b64) {
7975
8673
  return out;
7976
8674
  }
7977
8675
 
8676
+ // node_modules/@bod.ee/voice/src/adapters/fish.ts
8677
+ var log15 = forComponent2("FishTTS");
8678
+
8679
+ // node_modules/@bod.ee/voice/src/adapters/grok.ts
8680
+ var log16 = forComponent2("GrokTTS");
8681
+
8682
+ // node_modules/@bod.ee/voice/src/adapters/grok-stt.ts
8683
+ var log17 = forComponent2("GrokSTT");
8684
+
7978
8685
  // src/voice/engine.ts
7979
8686
  init_logging();
7980
8687
  configureLogging((name) => {
@@ -7983,8 +8690,431 @@ configureLogging((name) => {
7983
8690
  return { debug: at("debug"), verbose: at("verbose"), info: at("info"), warn: at("warn"), error: at("error") };
7984
8691
  });
7985
8692
 
8693
+ // src/voice/instinct.ts
8694
+ init_logging();
8695
+ var log18 = forComponent("Instinct");
8696
+ var InstinctOptions = class {
8697
+ decider;
8698
+ /** Partial unchanged this long (a micro-pause) → consult. */
8699
+ stableMs = 250;
8700
+ /** Don't consult on fragments shorter than this. */
8701
+ minWords = 4;
8702
+ /** Act only on the TOP choice, and only above its floor (live eval 2026-09-23: a correct cut-in often
8703
+ * tops at 0.6-0.8; a mid-thought "we could maybe" drew a 0.58 backchannel that must not fire). */
8704
+ backchannelMin = 0.65;
8705
+ interjectMin = 0.6;
8706
+ yieldMin = 0.7;
8707
+ backchannelGapMs = 5e3;
8708
+ interjectGapMs = 2e4;
8709
+ /** A decision whose partial grew by this many words while in flight is stale → dropped. */
8710
+ staleWords = 3;
8711
+ /** Overlap: consult only once the engine has HELD the pause this long without ceding. The engine cedes on
8712
+ * the 2nd novel word (~200ms after the pause, live 2026-09-23) — a call fired at the pause lands after that
8713
+ * (~300-400ms) and can never act. A hold that survives this window is the undecided case (a 1-word overlap
8714
+ * the engine would RESUME over at overlapResumeMs=700) — the one where a yield actually changes the outcome. */
8715
+ overlapSettleMs = 250;
8716
+ /** ackFor waits this long for the host's ackOutcome before treating the ack as unspoken. Hosts set it to the engine's
8717
+ * last chance to fire (bod-voice: 2×adaptiveAckMs — past that a pending pick is dropped as pick_late). */
8718
+ ackSettleMs = 1500;
8719
+ context = () => ({});
8720
+ /** Actions return `false` when the host could not act (engine state moved on) → logged as `<act>_skipped`. */
8721
+ onBackchannel = () => {
8722
+ };
8723
+ onInterject = () => {
8724
+ };
8725
+ onYield = () => {
8726
+ };
8727
+ /** The overlap was judged a listener cue — carry on. Hosts whose engine HOLDS short overlaps for a verdict
8728
+ * (bod-voice overlapDecideMs) resume it here; otherwise leave the default no-op (the engine decides). */
8729
+ onKeepTalking = () => {
8730
+ };
8731
+ onDecision = () => {
8732
+ };
8733
+ now = () => performance.now();
8734
+ };
8735
+ var ACK_SITUATIONS = {
8736
+ hard_question: `a question that takes real thought, judgment or looking something up ("what's the biggest takeaway?", "how should I structure this?")`,
8737
+ simple_question: 'a quick question with an instant, obvious answer ("how are you?", "what time is it?", "can you hear me?")',
8738
+ emotional_bad: "they share something bad, sad, painful, worrying or frustrating",
8739
+ emotional_good: "they share good news, excitement, relief or pride",
8740
+ 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")`,
8741
+ surprise: `they react with surprise or disbelief to what you said ("oh, that's new", "really?", "no way", "oh.")`,
8742
+ agreement: `they agree with you, confirm, or tell you to go on ("exactly", "yes, that's it", "okay, so continue")`,
8743
+ goodbye: "they are wrapping up the conversation or saying goodbye",
8744
+ command: 'a request or instruction for you to do, find, check or tell them something ("tell me the news", "check my calendar")',
8745
+ 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")'
8746
+ };
8747
+ var ACK_BANK = {
8748
+ hard_question: ["Hmm, let me think on that.", "Ooh, interesting one.", "Hm, that's a big one.", "Let's see here."],
8749
+ simple_question: ["Oh, well, so\u2014", "Well, let's see\u2014", "Oh, easy one\u2014"],
8750
+ challenge: ["Fair question.", "Mm, fair to ask.", "Hm, okay, so\u2014", "Ah, I get why\u2014"],
8751
+ emotional_bad: ["Oh, I'm sorry.", "Ugh, that's rough.", "Aw, that's hard.", "Oh man, I'm sorry."],
8752
+ emotional_good: ["Oh, that's great!", "Oh, that's awesome!", "Oh, nice one!", "Love to hear it!"],
8753
+ surprise: ["Right? Pretty wild.", "Yeah, wild, huh?", "Huh, I know.", "Wild, isn't it?"],
8754
+ agreement: ["Mm, good, so\u2014", "Yeah, so\u2014", "Alright, so\u2014", "Yep, with you."],
8755
+ goodbye: ["Alright, catch you later.", "Great chatting.", "Okay, all set then."],
8756
+ command: ["On it.", "Sure, one sec.", "Got it, one moment.", "Sure thing, one sec."],
8757
+ silent: [],
8758
+ // interruption acks, keyed on the barge intent
8759
+ stop: ["Sure, stopping.", "Okay, stopping here.", "Got it, I'll stop."],
8760
+ aside: ["Oh, quick one\u2014", "Ah, sure, so\u2014", "Oh, right, so\u2014"],
8761
+ steer: ["Ah, okay, switching\u2014", "Ah, got it, so\u2014", "Oh, gotcha, sure\u2014"],
8762
+ resume: ["Right, so\u2014", "Anyway, so\u2014", "So, as I was saying\u2014"]
8763
+ };
8764
+ var INTENT_ACK_OVER = {
8765
+ stop: Object.keys(ACK_SITUATIONS).filter((s) => s !== "silent"),
8766
+ steer: ["command"],
8767
+ resume: ["agreement", "silent"],
8768
+ aside: []
8769
+ };
8770
+ function interruptionNote(cut, intent) {
8771
+ const full = cut.full.trim();
8772
+ let at = Math.min(cut.heard.length, cut.full.length);
8773
+ if (/\S/.test(cut.full[at] ?? "") && /\S/.test(cut.full[at - 1] ?? "")) at = cut.full.lastIndexOf(" ", at) + 1;
8774
+ const heardWords = cut.full.slice(0, at).trim().split(/\s+/).filter(Boolean).length;
8775
+ const unheard = cut.full.slice(at).trim();
8776
+ 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}".`;
8777
+ const how = {
8778
+ resume: "They did not ask anything new. Carry on with the part they missed \u2014 no restart, no apology.",
8779
+ 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.",
8780
+ steer: "They steered away. Follow their new direction; carry over only what still matters from your unfinished point, or drop it.",
8781
+ stop: "They want you to stop. Do not resume; at most a few words of acknowledgement.",
8782
+ 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."
8783
+ };
8784
+ return `
8785
+ [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.]`;
8786
+ }
8787
+ var FRAME = "You are the instinct of a warm, attentive human in a live spoken conversation. Decide what a natural human would do THIS instant \u2014 the way people do without thinking. ";
8788
+ var acted = (what, r) => r === false ? `${what}_skipped` : what;
8789
+ var words = (s) => s.trim() ? s.trim().split(/\s+/).length : 0;
8790
+ var Instinct = class {
8791
+ constructor(options) {
8792
+ this.options = options;
8793
+ this.options = { ...new InstinctOptions(), ...options };
8794
+ }
8795
+ options;
8796
+ partial = "";
8797
+ turnStart = 0;
8798
+ timer = null;
8799
+ inflight = false;
8800
+ lastBcAt = -Infinity;
8801
+ lastInterjectAt = -Infinity;
8802
+ lastIntent;
8803
+ overlapGen = 0;
8804
+ // bumped by overlapEnded / a newer overlap → cancels a pending or in-flight overlap consult
8805
+ lastAckPick;
8806
+ interjecting = false;
8807
+ // inside onInterject — see pickAck
8808
+ lastAckSaid = "";
8809
+ get o() {
8810
+ return this.options;
8811
+ }
8812
+ /** A live user partial while the agent is LISTENING. Consults once the partial goes stable. */
8813
+ hear(text) {
8814
+ const t = text.trim();
8815
+ if (!t || t === this.partial) return;
8816
+ this.partial = t;
8817
+ if (!this.turnStart) this.turnStart = this.o.now();
8818
+ if (this.timer) clearTimeout(this.timer);
8819
+ this.timer = setTimeout(() => {
8820
+ this.timer = null;
8821
+ void this.consult(t);
8822
+ }, this.o.stableMs);
8823
+ }
8824
+ /** The user's turn ended (final dispatched, or the agent took the floor) — forget the partial, and drop any
8825
+ * pending overlap consult: whatever hold it was about is over. */
8826
+ reset() {
8827
+ this.overlapGen++;
8828
+ if (this.timer) clearTimeout(this.timer);
8829
+ this.timer = null;
8830
+ this.partial = "";
8831
+ this.turnStart = 0;
8832
+ }
8833
+ async consult(heard) {
8834
+ if (this.inflight || words(heard) < this.o.minWords) return;
8835
+ this.inflight = true;
8836
+ try {
8837
+ const now4 = this.o.now();
8838
+ const d = await this.ask("listen", heard, {
8839
+ heard_so_far: heard,
8840
+ user_speaking_for_ms: Math.round(now4 - this.turnStart),
8841
+ ms_since_our_last_backchannel: Number.isFinite(this.lastBcAt) ? Math.round(now4 - this.lastBcAt) : null
8842
+ }, FRAME + "The user is mid-turn and just paused for a split second. Do you keep quiet, give a tiny listener cue, or cut in? Cut in only when a human really would: they are clearly wrong about something that matters, they are struggling for a word you know, or they asked something and are trailing off.", {
8843
+ stay_silent: "keep listening \u2014 they are mid-thought, nothing to add",
8844
+ backchannel: 'a tiny "mm-hm"/"right" to show you are following, without taking the turn (fits after a clause in a longer story or explanation)',
8845
+ interject: "cut in now with a short correction, the word they are reaching for, or an answer"
8846
+ });
8847
+ if (!d) return;
8848
+ if (!this.partial || words(this.partial) - words(heard) >= this.o.staleWords) return this.o.onDecision({ ...d, acted: "stale" });
8849
+ const p = d.probabilities, t = this.o.now();
8850
+ if (d.choice === "interject" && p.interject >= this.o.interjectMin && t - this.lastInterjectAt > this.o.interjectGapMs) {
8851
+ this.lastInterjectAt = t;
8852
+ const at = this.partial;
8853
+ this.reset();
8854
+ this.interjecting = true;
8855
+ let r;
8856
+ try {
8857
+ r = this.o.onInterject(at);
8858
+ } finally {
8859
+ this.interjecting = false;
8860
+ }
8861
+ this.o.onDecision({ ...d, acted: acted("interject", r) });
8862
+ } else if (d.choice === "backchannel" && p.backchannel >= this.o.backchannelMin && t - this.lastBcAt > this.o.backchannelGapMs) {
8863
+ this.lastBcAt = t;
8864
+ this.o.onDecision({ ...d, acted: acted("backchannel", this.o.onBackchannel()) });
8865
+ } else this.o.onDecision(d);
8866
+ } finally {
8867
+ this.inflight = false;
8868
+ }
8869
+ }
8870
+ /** The user started talking OVER the agent (engine paused on a genuine overlap). Yield or keep going?
8871
+ * Consulted only if the engine is STILL holding after overlapSettleMs — see overlapEnded(). */
8872
+ async overlap(heard) {
8873
+ if (!heard.trim()) return;
8874
+ const gen = ++this.overlapGen;
8875
+ if (this.o.overlapSettleMs > 0) await new Promise((r) => setTimeout(r, this.o.overlapSettleMs));
8876
+ if (gen !== this.overlapGen) return;
8877
+ const d = await this.ask(
8878
+ "overlap",
8879
+ heard,
8880
+ { user_said_over_us: heard },
8881
+ FRAME + "You are mid-sentence and the user just said something over you. Would a human stop and give them the floor, or treat it as a listener cue and carry on?",
8882
+ {
8883
+ yield: 'they want the floor \u2014 an objection, question, correction, "stop"/"wait", or new information',
8884
+ keep_talking: 'just a listener cue or agreement ("yeah", "mm-hm", "right", "okay") \u2014 carry on'
8885
+ }
8886
+ );
8887
+ if (d && gen !== this.overlapGen) this.o.onDecision({ ...d, acted: "stale" });
8888
+ else if (d && (d.probabilities.yield ?? 0) >= this.o.yieldMin) {
8889
+ this.o.onDecision({ ...d, acted: acted("yield", this.o.onYield(heard)) });
8890
+ } else if (d?.choice === "keep_talking") {
8891
+ this.o.onDecision({ ...d, acted: acted("keep_talking", this.o.onKeepTalking(heard)) });
8892
+ } else if (d) this.o.onDecision(d);
8893
+ }
8894
+ /** The engine's hold ended (it ceded the floor, resumed over a stalled overlap, began a new turn, or was
8895
+ * interrupted) — drop any pending/in-flight overlap consult. Hosts call this on every hold exit. */
8896
+ overlapEnded() {
8897
+ this.overlapGen++;
8898
+ }
8899
+ /** What the user meant by cutting us off mid-speech. Memoized per text (the duplex judge and the host's
8900
+ * note builder ask about the same line). Rejects on decider failure — callers pick their fallback. */
8901
+ bargeIntent(heard) {
8902
+ if (this.lastIntent?.heard === heard) return this.lastIntent.p;
8903
+ const p = this.ask(
8904
+ "barge_intent",
8905
+ heard,
8906
+ { user_said_after_cutting_us_off: heard },
8907
+ FRAME + "You were in the middle of saying something (what_we_said_last) and the user cut you off with this. What would a human take from it?",
8908
+ {
8909
+ resume: 'nothing new \u2014 a filler, apology, or "go on": just pick up where you left off',
8910
+ aside: "a quick question, comment, or clarification about what you were saying \u2014 answer it, then carry on with your point",
8911
+ steer: "they are redirecting \u2014 a different topic, a correction of your plan, or a new request that replaces what you were saying",
8912
+ stop: "they want you to stop talking or drop it"
8913
+ },
8914
+ true
8915
+ ).then((d) => {
8916
+ if (d) this.o.onDecision({ ...d, acted: d.choice });
8917
+ return d?.choice ?? "steer";
8918
+ });
8919
+ this.lastIntent = { heard, p };
8920
+ return p;
8921
+ }
8922
+ /** The ack for a finished user turn (engine `pickAck`, asked at dispatch): one Choice over ACK_SITUATIONS, run in
8923
+ * parallel with the reflex. After a barge the interruption's intent (bargeIntent, memoized — the same call the note
8924
+ * builder makes) replaces the situations in INTENT_ACK_OVER with its own ack; the rest keep the situational ack.
8925
+ * `null` = say nothing. Never rejects: a failed Choice → silence (logged in ask) — a wrong ack is worse than none,
8926
+ * and a slow reflex still gets the dead-air repair.
8927
+ * A cut-in (asked from inside onInterject) is always silent: it has no dispatch, so the engine's context carries the
8928
+ * user's PREVIOUS final (live: "My dog died yesterday." re-acked "Oh no." before a cut-in correction), and the
8929
+ * unfinished partial is no turn to react to — the interjection itself is the reply. */
8930
+ pickAck(ctx) {
8931
+ if (this.interjecting) {
8932
+ log18.debug(`ack: cut-in turn \u2192 silent (engine context "${ctx.text.slice(0, 40)}" is the previous final)`);
8933
+ return Promise.resolve(null);
8934
+ }
8935
+ const text = ctx.text.trim();
8936
+ const p = (async () => {
8937
+ const intentP = ctx.barged ? this.bargeIntent(text).catch((e) => {
8938
+ log18.warn(`barge intent for the ack failed \u2014 situational ack: ${e.message}`);
8939
+ return null;
8940
+ }) : null;
8941
+ const d = await this.ask(
8942
+ "ack",
8943
+ text,
8944
+ { user_just_said: text },
8945
+ FRAME + "The user just finished speaking (user_just_said) and your real reply is a moment away. Classify what they said, so the fitting quick acknowledgement can be voiced first. Pick the category that matches their words; most requests and questions are NOT silent.",
8946
+ ACK_SITUATIONS
8947
+ );
8948
+ const sit = d?.choice ?? "silent";
8949
+ const intent = intentP ? await intentP : null;
8950
+ const key = intent && INTENT_ACK_OVER[intent].includes(sit) ? intent : sit;
8951
+ const phrase = this.rotate(ACK_BANK[key] ?? []);
8952
+ if (d) this.o.onDecision({ ...d, acted: phrase ? `${key}: ${phrase}` : `${key}: silent` });
8953
+ return phrase;
8954
+ })();
8955
+ let settle;
8956
+ const spoken = new Promise((r) => {
8957
+ settle = r;
8958
+ });
8959
+ const timer = setTimeout(() => {
8960
+ log18.debug(`ack: no engine outcome in ${this.o.ackSettleMs}ms \u2014 not claimed`);
8961
+ settle(null);
8962
+ }, this.o.ackSettleMs);
8963
+ void spoken.then(() => clearTimeout(timer));
8964
+ this.lastAckPick = { text, p, spoken, settle };
8965
+ return p;
8966
+ }
8967
+ /** The host reports what the engine did with the armed ack: the phrase it actually spoke, or null (silent, late,
8968
+ * cancelled). First report per pick wins — a later turn's cancel of an already-spoken ack changes nothing. */
8969
+ ackOutcome(said) {
8970
+ this.lastAckPick?.settle(said);
8971
+ }
8972
+ /** What was said out loud to open the reply to this exact text — lets a host tell the reply turn, without a second
8973
+ * call. Only an ack the engine actually SPOKE (ackOutcome): a pick that lands after the engine's deadline is never
8974
+ * claimed. null: nothing picked, not spoken, not asked, or no outcome within ackSettleMs of the pick's start.
8975
+ * Never waits past that cap (a hung pick included), and returns as soon as the outcome settles. */
8976
+ async ackFor(text) {
8977
+ const pick = this.lastAckPick;
8978
+ if (pick?.text !== text.trim()) return null;
8979
+ return Promise.race([pick.p.then((p) => p ? pick.spoken : null), pick.spoken]);
8980
+ }
8981
+ /** A random phrase from `pool`, never the one said last (no back-to-back repeat across keys). */
8982
+ rotate(pool) {
8983
+ const fresh = pool.filter((x) => x !== this.lastAckSaid);
8984
+ const from = fresh.length ? fresh : pool;
8985
+ if (!from.length) return null;
8986
+ return this.lastAckSaid = from[Math.floor(Math.random() * from.length)];
8987
+ }
8988
+ async ask(kind, heard, state, instructions, criteria, rethrow = false) {
8989
+ const t0 = this.o.now();
8990
+ try {
8991
+ const ctx = this.o.context();
8992
+ const { answers } = await this.o.decider.decide({
8993
+ state: { ...state, what_we_said_last: ctx.agentSaid || null, recent_conversation: ctx.recent || null },
8994
+ questions: { act: { type: "choice", instructions, criteria } }
8995
+ });
8996
+ return { kind, heard, choice: answers.act.choice, probabilities: answers.act.probabilities, ms: Math.round(this.o.now() - t0) };
8997
+ } catch (e) {
8998
+ log18.warn(`${kind} decision failed \u2014 falling back to engine defaults: ${e.message}`);
8999
+ if (rethrow) throw e;
9000
+ return null;
9001
+ }
9002
+ }
9003
+ };
9004
+
9005
+ // src/voice/unsaid.ts
9006
+ init_logging();
9007
+ var log19 = forComponent("Unsaid");
9008
+ 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(" "));
9009
+ var contentStems = (s) => (s.toLowerCase().match(/[a-z0-9']+/g) ?? []).filter((w) => w.length >= 4 && !STOP2.has(w)).map((w) => w.slice(0, 4));
9010
+ var SAID_OVERLAP = 0.6;
9011
+ var UNSAID_OVERLAP = 0.25;
9012
+ var CONVEYED_MIN = 0.3;
9013
+ function splitClauses(text) {
9014
+ return text.split(/(?<=[.!?…])\s+|(?<=;)\s+|(?<=,)\s+(?=(?:and|but)\b)/i).map((s) => s.trim()).filter((s) => contentStems(s).length > 0);
9015
+ }
9016
+ var overlap = (clause, said) => {
9017
+ const w = contentStems(clause);
9018
+ return w.filter((x) => said.has(x)).length / w.length;
9019
+ };
9020
+ 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.";
9021
+ async function unsaidClauses(pieces, spoken, decider, timeoutMs = 5e3) {
9022
+ const said = new Set(contentStems(spoken));
9023
+ const open = splitClauses(pieces.join(" ")).map((c) => ({ c, r: overlap(c, said) })).filter((x) => x.r < SAID_OVERLAP);
9024
+ if (!open.length) return [];
9025
+ const fallback = () => open.filter((x) => x.r < UNSAID_OVERLAP).map((x) => x.c);
9026
+ if (!decider) return fallback();
9027
+ const state = { assistant_said: spoken };
9028
+ const questions = {};
9029
+ open.forEach((x, i) => {
9030
+ state[`c${i}`] = x.c;
9031
+ 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` } };
9032
+ });
9033
+ let timer;
9034
+ try {
9035
+ const { answers } = await Promise.race([
9036
+ decider.decide({ state, questions }),
9037
+ new Promise((_, rej) => {
9038
+ timer = setTimeout(() => rej(new Error(`no verdict in ${timeoutMs}ms`)), timeoutMs);
9039
+ })
9040
+ ]);
9041
+ return open.filter((_, i) => {
9042
+ const p = Number(answers[`c${i}`]?.noul);
9043
+ if (!Number.isFinite(p)) throw new Error(`no noul for c${i}`);
9044
+ return p < CONVEYED_MIN;
9045
+ }).map((x) => x.c);
9046
+ } catch (e) {
9047
+ log19.warn(`unsaid judge failed \u2014 only near-zero-overlap clauses count as unsaid: ${e.message}`);
9048
+ return fallback();
9049
+ } finally {
9050
+ clearTimeout(timer);
9051
+ }
9052
+ }
9053
+
9054
+ // src/voice/leak.ts
9055
+ var TOOL = String.raw`(?:QuickLook|TaskStatus|CancelTask|AnswerTask|ExitSession|Act|Think|Hold(?! on\b))`;
9056
+ var SUSPECT = [
9057
+ /\b(?:user|assistant)\b/i,
9058
+ /\b(?:instructions?|guidelines?|polic(?:y|ies)|system prompt|developer (?:message|instructions?|prompt))\b/i,
9059
+ new RegExp(String.raw`\b${TOOL}\b`),
9060
+ /\bwe (?:need to|have to|should|must|ought to)\b|(?:^|[.!?]\s*)(?:need to|should|must|let's)\b/i,
9061
+ /\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,
9062
+ /\b(?:respond|reply|output|produce|comply|acknowledge?)\w*\b/i,
9063
+ /\bwe(?: have|'ve)\b|\bwe (?:already )?(?:responded|replied|dispatched|used|called|did|gave|said|asked)\b/i,
9064
+ /\bno (?:further|more|other)\b/i,
9065
+ /\[\s*task\b|\bt\d+\b|\bwait(?:ing)? for (?:the )?(?:events?|results?|task)\b/i,
9066
+ /\w\s*\{\s*"|\bwhat:\s*"/,
9067
+ /\b(?:last|previous) (?:response|reply|message|turn)\b|\bthe (?:conversation|transcript|context)\b/i
9068
+ ];
9069
+ var STRICT = [
9070
+ // the listener as a third-person subject opening the sentence ("The user wants…", telegraphic "User wants…")
9071
+ /^(?: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,
9072
+ /\b(?:if|when|since|because) user (?:is|keeps|wants|says|said|asks|asked|seems)\b/i,
9073
+ /\bwe have (?:an? )?(?:user (?:who|request|message|asking|saying)|conversation (?:where|with)|new user message)\b|^we have task\b/i,
9074
+ /\bthe assistant(?:'s (?:context|last|previous) (?:response|reply|turn|message)| (?:responded|replied) with)\b/i,
9075
+ // planning its own reply
9076
+ /\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,
9077
+ /\b(?:we|i) (?:need to|should|must) output\b|^(?:we|i) must comply[.!]?$/i,
9078
+ /\bno (?:further|more|other) (?:response|reply|output)s? (?:is )?(?:needed|required|necessary)\b/i,
9079
+ /\bwe(?: have|'ve)? (?:already )?(?:responded|replied)[.!]?$/i,
9080
+ // its own protocol: using its tools, tool-call syntax, task events
9081
+ 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`),
9082
+ 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`),
9083
+ /\bwait(?:ing)? for (?:the )?(?:events?|t\d+)\b|\bt\d+ (?:completed|failed)\b|\[\s*task\b/i
9084
+ ];
9085
+ var SENTENCE_END = new RegExp(`[.!?\u2026]+["')\\]]*(?:\\s|(?=\\p{Lu}|\\[))`, "gu");
9086
+ var SENTENCE_SPLIT = new RegExp(`(?<=[.!?\u2026]+["')\\]]*)(?:\\s+|(?=\\p{Lu}|\\[))`, "u");
9087
+ var clean = (s) => s.replace(/\[[a-z_ ]+\]\s*/gi, "").trim();
9088
+ function isLeakSuspect(sentence) {
9089
+ const s = clean(sentence);
9090
+ return SUSPECT.some((re) => re.test(s));
9091
+ }
9092
+ function isStrictLeak(sentence) {
9093
+ return sentence.split(SENTENCE_SPLIT).some((x) => {
9094
+ const s = clean(x);
9095
+ return STRICT.some((re) => re.test(s));
9096
+ });
9097
+ }
9098
+ 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.`;
9099
+ var LEAK_JUDGE_THRESHOLD = 0.6;
9100
+ async function judgeLeak(decider, sentence, userSaid) {
9101
+ const { answers } = await decider.decide({
9102
+ state: { what_the_user_just_said: userSaid.slice(-600) || null, sentence_about_to_be_spoken: clean(sentence) },
9103
+ questions: {
9104
+ q: {
9105
+ type: "noul",
9106
+ instructions: JUDGE_INSTRUCTIONS,
9107
+ criteria: { true: "leaked private reasoning \u2014 must not be spoken", false: "said to the listener \u2014 fine to speak" }
9108
+ }
9109
+ }
9110
+ });
9111
+ const p = Number(answers.q.noul);
9112
+ if (!Number.isFinite(p)) throw new Error("leak judge: no noul in the answer");
9113
+ return p;
9114
+ }
9115
+
7986
9116
  // src/duplex.ts
7987
- var log15 = forComponent("DuplexAgent");
9117
+ var log20 = forComponent("DuplexAgent");
7988
9118
  function describeCall(call) {
7989
9119
  const v = call.args && Object.values(call.args).find((x) => typeof x === "string" && x.trim());
7990
9120
  const hint = v ? ` (${String(v).replace(/\s+/g, " ").trim().slice(0, 48)})` : "";
@@ -8026,6 +9156,13 @@ var DuplexAgentOptions = class {
8026
9156
  /** Teach the model to emit inline `[emotion]` tags for Cartesia emotion control. Only set when the
8027
9157
  * TTS actually speaks them — text-duplex (no TTS) would otherwise print literal tags. */
8028
9158
  emotionTags = false;
9159
+ /** Judge a post-barge line while a delivery is parked: resume it now, answer an aside and carry it on (the reflex,
9160
+ * told what was heard — see asideNote), or drop it (steer/stop). Unset → the conservative `isTrivialBarge` allowlist (resume | steer); a throwing judge
9161
+ * falls back to it too. */
9162
+ bargeIntent;
9163
+ /** Typed-decision model (e.g. ai.libx.js DecideClient → Jev). Judges reflex sentences the leak screen flags as
9164
+ * SUSPECT (src/voice/leak.ts) — only suspects pay the ~300ms call. Unset → only the strict leak shapes are cut. */
9165
+ decider;
8029
9166
  /** Awaited BEFORE a worker spawns — open a per-task checkpoint frame, audit, etc.
8030
9167
  * (post-spawn would race the worker's first edits). */
8031
9168
  onTaskStart;
@@ -8034,6 +9171,13 @@ var DuplexAgentOptions = class {
8034
9171
  progressUpdates = false;
8035
9172
  /** Min ms between progress re-voices per task. */
8036
9173
  progressIntervalMs = 25e3;
9174
+ /** Spoken budget of ONE task delivery (chars, ~3 short sentences). Past it the rest is parked — cut at a sentence
9175
+ * boundary, `spokenOffer` spoken instead — so "go on" continues it once (the parked/resume path) and an aside or
9176
+ * steer handles it like any parked delivery. The worker is also asked for a short spoken summary + one offer (the
9177
+ * source); this is the backstop. Live 20260924-093945: ~20s and ~70s monologues whose closing choice went unspoken.
9178
+ * The full result always folds into the transcript. 0 = unbudgeted. */
9179
+ spokenBudgetChars = 0;
9180
+ spokenOffer = "There's more. Want me to go on?";
8037
9181
  /** Relay worker questions (AskUserQuestion + permission asks via parkQuestion) through the VOICE:
8038
9182
  * the question re-voices as '[task <id> asks] …', the user answers conversationally, and the
8039
9183
  * voice model resolves it with the AnswerTask tool. Off → host.ask passthrough (text menus). */
@@ -8052,20 +9196,30 @@ var DuplexAgentOptions = class {
8052
9196
  /** User-scope memory dir for global facts (type=user/feedback). Forwarded to Remember's routing. */
8053
9197
  memoryUserDir;
8054
9198
  };
9199
+ var SESSION_ID_RE = /^\d{8}-\d{6}(?:-[\w-]+)?$/;
8055
9200
  var RESERVED_EVENT_MARKER = /\[task\b[^\]\n]*\b(?:completed|failed|progress|asks)\b/i;
8056
9201
  var RESERVED_EVENT_OPENER = /\[\s*task\b/i;
8057
9202
  var STAGE_DIRECTION_RE = /^\(\s*(?:(?:waiting|checking|searching|thinking|processing|loading|working|fetching|looking)\b[^)]*|[^)]*(?:\.\.\.|…)\s*)\)$/i;
8058
9203
  function nowLine() {
8059
9204
  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" })}`;
8060
9205
  }
9206
+ 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/;
8061
9207
  function isTrivialBarge(text) {
8062
9208
  const t = text.trim().toLowerCase().replace(/[.,!?…\s]+$/g, "").replace(/^[.,!?…\s]+/, "");
8063
9209
  if (!t) return false;
8064
- 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;
9210
+ if (STOP_INTENT.test(t)) return false;
8065
9211
  const core = t.replace(/^(?:oh|um+|uh+|er+|hmm+|ah+|well|so|okay|ok|yeah|yep)[\s,]+/, "").trim();
8066
9212
  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);
8067
9213
  }
8068
- 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.';
9214
+ function isOfferAcceptance(text) {
9215
+ const t = text.trim().toLowerCase().replace(/[.,!?…]+/g, " ").replace(/\s+/g, " ").trim();
9216
+ if (!t || STOP_INTENT.test(t)) return false;
9217
+ const core = t.replace(/^(?:oh|um+|uh+|well|so|okay|ok|alright)\s+/, "");
9218
+ 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);
9219
+ }
9220
+ var isQuestion = (s) => /\?["'”’)\]]*$/.test(s.trim());
9221
+ var OFFER_OVERRUN = 150;
9222
+ 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.';
8069
9223
  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.";
8070
9224
  var THINK_DISABLED_GUIDANCE = "(Think tier is not available \u2014 use Act for all escalations.)";
8071
9225
  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).`;
@@ -8080,14 +9234,22 @@ var DuplexAgent = class _DuplexAgent {
8080
9234
  * (TaskRecord.createdTurn) so parking can tell a prior-topic task from the current turn's fresh one. */
8081
9235
  turn = 0;
8082
9236
  pendingEvents = [];
8083
- /** Spoken text of parked deliveries that have SETTLED, awaiting a trivial-barge resume decision on the
8084
- * next turn (a substantive turn clears it — the result stays in the transcript, recoverable by asking). */
9237
+ /** THE parked delivery: every piece the user has NOT heard, in speaking order — the unheard rest a barge cut out of
9238
+ * the voice queue (parkCutDelivery) followed by each segment a barge-parked task produced since (deliverSegment).
9239
+ * Nothing already voiced is ever added, so a resume never repeats what was heard. The next line's barge intent
9240
+ * decides: resume → spoken verbatim, aside → handed to the reflex to carry on after its answer (asideNote) — both
9241
+ * send the barge-parked tasks live again; steer/stop → cleared, and those tasks
9242
+ * are moved on from (the full result stays in the transcript, recoverable by asking). `task` = the owner, so a
9243
+ * task whose outcome goes to an integration turn takes its pieces back (dropParked). */
8085
9244
  parkedRedeliver = [];
8086
- /** A trivial barge arrived while a parked delivery was STILL RUNNING → resume it the moment it settles. */
8087
- awaitTrivialRedeliver = false;
8088
- /** Set by a SUBSTANTIVE turn after a barge: the user moved on, so a parked delivery that settles LATER
8089
- * must NOT arm a resume (else a much-later "go on" replays a stale result). Reset by a fresh barge. */
8090
- suppressParkedResume = false;
9245
+ /** parkedRedeliver length when the last barge parked (parkInFlightDeliveries): the cut it hands over later
9246
+ * (parkCutDelivery) was voiced BEFORE anything parked since, so it is inserted here. */
9247
+ bargeMark = 0;
9248
+ /** The last barge cut a REFLEX reply (parkCutDelivery): a "go on" is then the reflex's to answer — it runs a normal
9249
+ * turn (the host's interruption note continues the reply), and only then does the parked delivery resume. */
9250
+ reflexCut = false;
9251
+ /** Recent live task segments → their task id: attributes a cut delivery (parkCutDelivery) to the task that spoke it. */
9252
+ voicedBy = /* @__PURE__ */ new Map();
8091
9253
  /** Out-of-band follow-up attribution for the events coalescing into the next flush turn: TRUE iff ≥1 of
8092
9254
  * the tasks being integrated was NON-CLEAN (early-stop/failure). Carried out-of-band on the enqueue call
8093
9255
  * by the caller that KNOWS the outcome — a plain boolean the MODEL CANNOT PERTURB. It is NOT scanned from
@@ -8120,6 +9282,24 @@ var DuplexAgent = class _DuplexAgent {
8120
9282
  // chars of reflexBuf already forwarded to the host/TTS
8121
9283
  fabricationCut = false;
8122
9284
  // reflex emitted a reserved [task …] marker → suppress its tail
9285
+ leakCuts = [];
9286
+ // heads of analysis-register leaks cut this turn — scrubbed from the transcript at turn end
9287
+ leakStep = false;
9288
+ // the CURRENT step's tail is muted by a leak cut (the next step speaks again, unlike a fabricated marker)
9289
+ leakHit = false;
9290
+ // this turn (or its repair) had a leak cut — the dead-air fallback must not blame the user
9291
+ stepMarks = [];
9292
+ // reflexBuf offsets where a step ended (its text is complete there)
9293
+ leakVerdicts = /* @__PURE__ */ new Map();
9294
+ // judged suspect sentences this turn (true = leak)
9295
+ judging;
9296
+ // the in-flight leak judge (one at a time — the gate is sequential)
9297
+ turnGen = 0;
9298
+ // bumped whenever the gated stream is replaced — a late verdict for an old one is dropped
9299
+ turnSpoken = "";
9300
+ // reflex text actually forwarded to the host this turn
9301
+ turnBase = 0;
9302
+ // transcript length at turn start (scope for the leak scrub)
8123
9303
  /** TRUE for the duration of a re-voice turn that is integrating ≥1 NON-CLEAN task (turn-eligibility,
8124
9304
  * carried out-of-band — NOT derived from any worker/brief string). ANY Act/Think dispatched in such a
8125
9305
  * turn is stamped followUp:true. This GUARANTEES the dangerous direction is impossible: a genuine
@@ -8168,7 +9348,8 @@ var DuplexAgent = class _DuplexAgent {
8168
9348
  ...new Set(workerToolNames.filter((n) => n.startsWith("mcp__")).map((n) => n.slice(5).split("__")[0]))
8169
9349
  ];
8170
9350
  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' : "") : "";
8171
- 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 : "") + `
9351
+ const prompt = VOICE_SYSTEM_PROMPT.replace("{{MEMORY_SLOT}}", memSlot).replace("{{THINK_SLOT}}", thinkSlot).replace("{{WORKER_WEB}}", workerWeb + workerMcp) + (o.quickLook?.sessions ? `
9352
+ 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 : "") + `
8172
9353
  ${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.`;
8173
9354
  const tools = [
8174
9355
  ...o.reflexOptions?.tools ?? [],
@@ -8189,32 +9370,26 @@ ${nowLine()}. Anchor every relative or time-sensitive reference \u2014 "today",
8189
9370
  if (this.heldThisTurn) return;
8190
9371
  if (this.fabricationCut) return;
8191
9372
  if (this.turnDispatched && this.spokeBeforeDispatch) return;
8192
- const msg = ev.message;
8193
- this.reflexBuf += msg;
8194
- this.scrubStageDirections();
9373
+ this.reflexBuf += ev.message;
8195
9374
  const m = this.reflexBuf.match(RESERVED_EVENT_MARKER) ?? this.reflexBuf.match(RESERVED_EVENT_OPENER);
8196
9375
  if (m) {
8197
9376
  this.fabricationCut = true;
8198
- log15.warn(`reflex fabricated a [task \u2026] event in its spoken stream \u2014 cutting it (kept ${m.index} chars)`);
8199
- const safe = this.reflexBuf.slice(this.reflexForwarded, m.index);
8200
- if (!safe) return;
8201
- if (safe.trim()) this.spokeThisTurn = true;
8202
- this.emitHost({ ...ev, message: safe });
9377
+ log20.warn(`reflex fabricated a [task \u2026] event in its spoken stream \u2014 cutting it (kept ${m.index} chars)`);
9378
+ this.reflexBuf = this.reflexBuf.slice(0, Math.max(m.index, this.reflexForwarded));
9379
+ this.pumpReflex(true);
8203
9380
  return;
8204
9381
  }
8205
- const held2 = this.reflexBuf.length - this.reflexForwarded;
8206
- const partial = held2 > 0 && /\[\s*t?a?s?k?$/i.test(this.reflexBuf.slice(-Math.min(held2, 6)));
8207
- let upto = partial ? this.reflexBuf.length - this.reflexBuf.slice(-6).match(/\[\s*t?a?s?k?$/i)[0].length : this.reflexBuf.length;
8208
- const paren = this.reflexBuf.lastIndexOf("(");
8209
- if (paren >= this.reflexForwarded && !this.reflexBuf.includes(")", paren) && this.reflexBuf.length - paren <= 80)
8210
- upto = Math.min(upto, paren);
8211
- const out = this.reflexBuf.slice(this.reflexForwarded, upto);
8212
- this.reflexForwarded = upto;
8213
- if (!out) return;
8214
- if (out.trim()) this.spokeThisTurn = true;
8215
- this.emitHost({ ...ev, message: out });
9382
+ this.pumpReflex(false);
8216
9383
  return;
8217
9384
  }
9385
+ if (ev?.kind === "turn_start" || ev?.kind === "tool_use") {
9386
+ this.stepMarks.push(this.reflexBuf.length);
9387
+ if (this.leakStep) {
9388
+ this.leakStep = false;
9389
+ this.reflexForwarded = this.reflexBuf.length;
9390
+ }
9391
+ this.pumpReflex(false);
9392
+ }
8218
9393
  host.notify?.(ev);
8219
9394
  }
8220
9395
  };
@@ -8248,16 +9423,163 @@ ${nowLine()}. Anchor every relative or time-sensitive reference \u2014 "today",
8248
9423
  this.voice.options.tools.push(...mem.tools);
8249
9424
  if (mem.index) this.voice.options.systemPrompt += "\n\n" + mem.index;
8250
9425
  }
8251
- /** Flush any held-back trailing fragment (a possible `[task` opener that never completed) once the
8252
- * turn's stream is done — so a legit message ending in "[t" isn't silently dropped. */
8253
- flushHeldReflexTail() {
8254
- if (this.fabricationCut) return;
8255
- this.scrubStageDirections();
8256
- const tail = this.reflexBuf.slice(this.reflexForwarded);
8257
- this.reflexForwarded = this.reflexBuf.length;
8258
- if (!tail) return;
8259
- if (tail.trim()) this.spokeThisTurn = true;
8260
- this.emitHost({ kind: "text_delta", message: tail });
9426
+ /** Forward what the reflex's spoken-stream gates allow (text_delta → host/TTS; forwarded audio can't be unsent):
9427
+ * - hold a trailing fragment that could be a `[task` opener split across deltas ("[ta" + "sk"), and an UNCLOSED
9428
+ * "(" (a stage direction is judged — and dropped — only once its ")" arrives; bounded at 80 chars);
9429
+ * - the leak gate: whole sentences only, each screened by isLeakSuspect the moment it completes — a strict match
9430
+ * is cut, any other suspect waits for the decider's verdict (judgeLeak) while the rest queues behind it.
9431
+ * `final` = the stream is done: release the holds and judge whatever is left as a sentence. Text before a step
9432
+ * boundary (stepMarks) is complete — no holds, its tail is a sentence. Idempotent; re-run as verdicts land. */
9433
+ pumpReflex(final) {
9434
+ for (; ; ) {
9435
+ if (this.leakStep) return;
9436
+ this.scrubStageDirections();
9437
+ const from = this.reflexForwarded;
9438
+ const buf = this.reflexBuf;
9439
+ const lastMark = Math.max(from, ...this.stepMarks);
9440
+ let upto = buf.length;
9441
+ if (!final && upto > lastMark) {
9442
+ const tail = buf.slice(Math.max(lastMark, upto - 6)).match(/\[\s*t?a?s?k?$/i);
9443
+ if (tail) upto -= tail[0].length;
9444
+ const paren = buf.lastIndexOf("(");
9445
+ if (paren >= lastMark && !buf.includes(")", paren) && buf.length - paren <= 80) upto = Math.min(upto, paren);
9446
+ }
9447
+ if (upto <= from) return;
9448
+ const region = buf.slice(from, upto);
9449
+ const ends = [...region.matchAll(SENTENCE_END)].map((m) => m.index + m[0].length);
9450
+ for (const k of this.stepMarks) if (k > from && k <= upto) ends.push(k - from);
9451
+ if (final || !ends.length && region.length >= 160) ends.push(region.length);
9452
+ ends.sort((a, b) => a - b);
9453
+ let ok = 0, leak;
9454
+ for (const e of ends) {
9455
+ if (e <= ok) continue;
9456
+ const v = this.leakVerdict(region.slice(ok, e));
9457
+ if (v === void 0) break;
9458
+ if (v) {
9459
+ leak = region.slice(ok, e);
9460
+ break;
9461
+ }
9462
+ ok = e;
9463
+ }
9464
+ this.forwardReflex(from + ok);
9465
+ if (leak === void 0) return;
9466
+ const at = from + ok;
9467
+ this.leakHit = true;
9468
+ this.leakCuts.push(leak.trim().slice(0, 40));
9469
+ log20.warn(`reflex spoke its own analysis ("${leak.trim().slice(0, 40)}\u2026") \u2014 cut before TTS`);
9470
+ this.notify("diag", "analysis_leak", { text: leak.trim().slice(0, 80), at: this.turnSpoken.trim() ? "mid_reply" : "before_speech" });
9471
+ const next = this.stepMarks.find((k) => k > at);
9472
+ if (next === void 0) {
9473
+ this.reflexForwarded = buf.length;
9474
+ this.leakStep = true;
9475
+ return;
9476
+ }
9477
+ this.reflexForwarded = next;
9478
+ }
9479
+ }
9480
+ forwardReflex(to) {
9481
+ const out = this.reflexBuf.slice(this.reflexForwarded, to);
9482
+ this.reflexForwarded = Math.max(this.reflexForwarded, to);
9483
+ if (!out) return;
9484
+ if (out.trim()) this.spokeThisTurn = true;
9485
+ this.turnSpoken += out;
9486
+ this.emitHost({ kind: "text_delta", message: out });
9487
+ }
9488
+ /** Leak verdict for one complete sentence: false = speak, true = cut, undefined = the judge is still out.
9489
+ * Non-suspects pass free; strict shapes are cut without a call; other suspects go to the decider (if any) —
9490
+ * one call at a time, cached per turn. A failed/slow judge fails OPEN (speech is never muted on a guess). */
9491
+ leakVerdict(sentence) {
9492
+ const s = sentence.trim();
9493
+ if (!s || !isLeakSuspect(s)) return false;
9494
+ if (isStrictLeak(s)) return true;
9495
+ const decider = this.options.decider;
9496
+ if (!decider) return false;
9497
+ const known = this.leakVerdicts.get(s);
9498
+ if (known !== void 0 || this.judging) return known;
9499
+ const gen = this.turnGen, t0 = performance.now();
9500
+ const userSaid = [...this.voice.transcript].reverse().map((m) => m.role === "user" ? contentText(m.content) : "").find((t) => t && !t.startsWith("[reminder]")) ?? "";
9501
+ let timer;
9502
+ const timeout = new Promise((_, rej) => {
9503
+ timer = setTimeout(() => rej(new Error(`no verdict in ${_DuplexAgent.LEAK_JUDGE_TIMEOUT_MS}ms`)), _DuplexAgent.LEAK_JUDGE_TIMEOUT_MS);
9504
+ });
9505
+ this.judging = Promise.race([judgeLeak(decider, s, userSaid), timeout]).then((p) => {
9506
+ this.notify("diag", "leak_judge", { text: s.slice(0, 80), p: Math.round(p * 100) / 100, ms: Math.round(performance.now() - t0) });
9507
+ return p >= LEAK_JUDGE_THRESHOLD;
9508
+ }, (e) => {
9509
+ log20.warn(`leak judge failed \u2014 speaking the sentence: ${e.message}`);
9510
+ return false;
9511
+ }).then((leak) => {
9512
+ clearTimeout(timer);
9513
+ if (gen !== this.turnGen) return;
9514
+ this.judging = void 0;
9515
+ this.leakVerdicts.set(s, leak);
9516
+ this.pumpReflex(false);
9517
+ });
9518
+ return void 0;
9519
+ }
9520
+ static LEAK_JUDGE_TIMEOUT_MS = 1500;
9521
+ /** The stream is done: settle every pending verdict and release everything the gates still hold. */
9522
+ async drainReflex() {
9523
+ for (; ; ) {
9524
+ this.pumpReflex(true);
9525
+ if (!this.judging) return;
9526
+ await this.judging;
9527
+ }
9528
+ }
9529
+ /** Drop any in-flight judge of the current stream (its verdict must not pump a stream that moved on). */
9530
+ dropJudge() {
9531
+ this.turnGen++;
9532
+ this.judging = void 0;
9533
+ }
9534
+ /** Remove a cut analysis leak from this turn's assistant message(s) — left in, it primes the next turn to
9535
+ * leak again and reads as something we said. Keeps any legit prefix; drops a message left empty. */
9536
+ scrubLeakFromTranscript() {
9537
+ const keys = this.leakCuts.splice(0);
9538
+ const t = this.voice.transcript;
9539
+ for (const key of keys) {
9540
+ let i = t.length - 1;
9541
+ while (i >= this.turnBase && !(t[i].role === "assistant" && typeof t[i].content === "string" && t[i].content.includes(key))) i--;
9542
+ if (i < this.turnBase) {
9543
+ log20.warn("analysis leak cut, but its text was not found in the transcript to scrub");
9544
+ continue;
9545
+ }
9546
+ const m = t[i];
9547
+ m.content = m.content.slice(0, m.content.indexOf(key)).trimEnd();
9548
+ if (!m.content && !m.tool_calls?.length) t.splice(i, 1);
9549
+ }
9550
+ }
9551
+ /** A Hold turn is silent BY DESIGN: every reflex text after the Hold is muted (voiceHost drops it), so none of it may
9552
+ * persist either — the transcript is what the agent SAID. Live 20260923-125143 msg 10: the model's reasoning ("We
9553
+ * need to respond according to guidelines… So we should hold") was saved as an assistant reply and fed every later
9554
+ * turn. Strips the text of assistant messages after the Hold call (tool calls stay — they happened); a message left
9555
+ * empty is removed. */
9556
+ scrubHeldText() {
9557
+ if (!this.heldThisTurn) return;
9558
+ const t = this.voice.transcript;
9559
+ let i = this.turnBase;
9560
+ while (i < t.length && !t[i].tool_calls?.some((c) => c.function.name === "Hold")) i++;
9561
+ for (let j = t.length - 1; j > i; j--) {
9562
+ const m = t[j];
9563
+ if (m.role !== "assistant" || !contentText(m.content).trim()) continue;
9564
+ if (m.tool_calls?.length) m.content = "";
9565
+ else t.splice(j, 1);
9566
+ }
9567
+ }
9568
+ /** Shared turn epilogue (user turn, confirmed speculation, re-voice): release held text, scrub a cut leak,
9569
+ * then repair dead air — unless the turn was ABORTED: the user took the floor (barge-in / Esc), which is
9570
+ * not dead air, and a nudge would run on the same aborted signal (live: the "[reminder]" landed in the
9571
+ * transcript as a user turn with no reply, session 20260923-101910 msg 27). */
9572
+ async endTurn(res, fallback) {
9573
+ if (res?.finishReason === "aborted") {
9574
+ this.dropJudge();
9575
+ this.scrubLeakFromTranscript();
9576
+ this.scrubHeldText();
9577
+ return;
9578
+ }
9579
+ await this.drainReflex();
9580
+ this.scrubLeakFromTranscript();
9581
+ this.scrubHeldText();
9582
+ if (this.silentTurn) await this.ackIfSilent(fallback);
8261
9583
  }
8262
9584
  /** Remove complete stage-direction parentheticals from the UNFORWARDED reflex text (STAGE_DIRECTION_RE).
8263
9585
  * Only the unforwarded region is touched — already-spoken audio can't be unsent, and splicing before
@@ -8279,6 +9601,14 @@ ${nowLine()}. Anchor every relative or time-sensitive reference \u2014 "today",
8279
9601
  this.reflexBuf = "";
8280
9602
  this.reflexForwarded = 0;
8281
9603
  this.fabricationCut = false;
9604
+ this.leakCuts = [];
9605
+ this.leakStep = this.leakHit = false;
9606
+ this.stepMarks = [];
9607
+ this.leakVerdicts.clear();
9608
+ this.turnSpoken = "";
9609
+ this.offerOpen = false;
9610
+ this.dropJudge();
9611
+ this.turnBase = this.voice.transcript.length;
8282
9612
  this.turnFollowUp = false;
8283
9613
  this.voice.options.toolChoice = void 0;
8284
9614
  }
@@ -8290,7 +9620,7 @@ ${nowLine()}. Anchor every relative or time-sensitive reference \u2014 "today",
8290
9620
  return {
8291
9621
  preToolUse: (call) => {
8292
9622
  if (this.spec?.state === "pending" && !_DuplexAgent.SPEC_SAFE_TOOLS.has(call.name)) {
8293
- log15.verbose(`speculation aborted: reflex called ${call.name} on unconfirmed input`);
9623
+ log20.verbose(`speculation aborted: reflex called ${call.name} on unconfirmed input`);
8294
9624
  this.abortSpeculation();
8295
9625
  return { block: true, reason: "Speculative turn aborted." };
8296
9626
  }
@@ -8324,16 +9654,27 @@ ${nowLine()}. Anchor every relative or time-sensitive reference \u2014 "today",
8324
9654
  * Wording adapts to whether work was dispatched (an ack) or the inline reply was simply lost. */
8325
9655
  async ackIfSilent(fallback) {
8326
9656
  const dispatched = this.turnDispatched;
9657
+ 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.";
9658
+ const base = this.voice.transcript.length;
9659
+ this.fabricationCut = this.leakStep = false;
9660
+ this.reflexBuf = "";
9661
+ this.reflexForwarded = 0;
9662
+ this.stepMarks = [];
9663
+ this.dropJudge();
8327
9664
  this.nudging = true;
8328
9665
  try {
8329
- 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.");
9666
+ await this.voice.send(reminder);
8330
9667
  } catch (e) {
8331
- log15.warn(`ack nudge failed: ${e instanceof Error ? e.message : e}`);
9668
+ log20.warn(`ack nudge failed: ${e instanceof Error ? e.message : e}`);
8332
9669
  } finally {
8333
9670
  this.nudging = false;
9671
+ const i = this.voice.transcript.findIndex((m, k) => k >= base && m.role === "user" && m.content === reminder);
9672
+ if (i >= 0) this.voice.transcript.splice(i, 1);
9673
+ await this.drainReflex();
9674
+ this.scrubLeakFromTranscript();
8334
9675
  }
8335
9676
  if (!this.spokeThisTurn) {
8336
- const pool = fallback ? [fallback] : dispatched ? _DuplexAgent.FALLBACK_ACKS : _DuplexAgent.FALLBACK_RETRY;
9677
+ const pool = fallback ? [fallback] : dispatched ? _DuplexAgent.FALLBACK_ACKS : this.leakHit ? _DuplexAgent.FALLBACK_OWN : _DuplexAgent.FALLBACK_RETRY;
8337
9678
  const fresh = pool.filter((p) => p !== this.lastFallback);
8338
9679
  const line = (fresh.length ? fresh : pool)[Math.floor(Math.random() * (fresh.length || pool.length))];
8339
9680
  this.lastFallback = line;
@@ -8344,26 +9685,134 @@ ${nowLine()}. Anchor every relative or time-sensitive reference \u2014 "today",
8344
9685
  * hosts/tests key on it. */
8345
9686
  static FALLBACK_ACKS = ["Okay, on it.", "On it.", "Alright, working on it."];
8346
9687
  static FALLBACK_RETRY = ["Sorry, could you say that again?", "Hm, I missed that \u2014 could you say that again?"];
9688
+ 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?"];
8347
9689
  lastFallback = "";
8348
9690
  /** One user turn: the voice agent streams the reply (and may Act/Think). Serialized with re-voice turns.
8349
9691
  * If a speculative reflex call is pending and this content CONFIRMS it (the final matches the
8350
9692
  * speculated partial), the in-flight call is adopted as THE turn: its buffered output flushes to the
8351
9693
  * host NOW (this is the latency win) and streaming continues live. Any other content aborts the
8352
- * speculation first (rolled back silently) and runs a normal turn behind it. */
8353
- send(content) {
8354
- if (typeof content === "string" && isTrivialBarge(content) && this.hasParkedDelivery()) {
8355
- if (this.spec) this.abortSpeculation();
8356
- return this.enqueue(async () => {
8357
- this.resetTurn();
8358
- if (!this.redeliverParked()) this.awaitTrivialRedeliver = true;
8359
- return { text: "", steps: 0, finishReason: "stop", messages: [] };
9694
+ * speculation first (rolled back silently) and runs a normal turn behind it.
9695
+ * `opts.id` names the turn; `opts.replaces` names an earlier one this content REPLACES — the voice engine's
9696
+ * fuller re-finalization of an utterance whose reply had not started ("…previous" → "…previous
9697
+ * conversation."). The replaced turn is skipped if it has not run yet, else aborted and rolled back out of
9698
+ * the transcript (kept only if it already dispatched work) — one reply, to the full text. */
9699
+ send(content, opts) {
9700
+ if (opts?.replaces) this.replaceTurn(opts.replaces);
9701
+ const cur = this.voice.options.signal;
9702
+ const ext = cur && cur === this.turnSignal?.own ? this.turnSignal.ext : cur;
9703
+ const rec = opts?.id ? this.trackTurn(opts.id) : void 0;
9704
+ if (rec?.superseded) {
9705
+ this.notify("diag", "turn_replaced", { id: opts.id, ran: false });
9706
+ return Promise.resolve(_DuplexAgent.replacedResult(this.voice.transcript));
9707
+ }
9708
+ const reflexCut = this.reflexCut;
9709
+ this.reflexCut = false;
9710
+ if (typeof content === "string" && this.hasParkedDelivery()) {
9711
+ const fallback = () => isTrivialBarge(content) ? "resume" : "steer";
9712
+ const accepted = this.offerOpen && this.parkedRedeliver.length > 0 && this.parkedRedeliver.every((p) => p.task === this.offer.task) && (isOfferAcceptance(content) || isTrivialBarge(content));
9713
+ const judge = accepted ? void 0 : this.options.bargeIntent;
9714
+ return (accepted ? Promise.resolve("resume") : judge ? judge(content).catch((e) => {
9715
+ log20.warn(`bargeIntent judge failed \u2014 using the allowlist: ${e.message}`);
9716
+ return fallback();
9717
+ }) : Promise.resolve(fallback())).then((intent) => {
9718
+ this.notify("diag", "barge_intent", { intent, text: content.slice(0, 80) });
9719
+ if (intent === "resume" && !reflexCut) return this.resumeParked();
9720
+ if (intent === "aside" || intent === "resume") {
9721
+ const told = intent === "aside" ? [...this.parkedRedeliver] : [];
9722
+ return this.sendTurn(intent === "aside" ? content + this.asideNote(told) : content, true, rec, ext).then(async (r) => {
9723
+ if (r.finishReason === "aborted" || this.heldThisTurn) return r;
9724
+ const missed = unsaidClauses(told.map((p) => p.text), this.turnSpoken, this.options.decider);
9725
+ const turn = this.turn;
9726
+ this.parkedRedeliver = this.parkedRedeliver.filter((p) => !told.includes(p));
9727
+ await this.resumeParked();
9728
+ void missed.then((m) => {
9729
+ const dropped = m.filter((c) => !isQuestion(c)).join(" ").replace(/[,;]$/, ".");
9730
+ if (!dropped || this.turn !== turn) return;
9731
+ this.parkedRedeliver.unshift({ text: dropped, task: told.at(-1)?.task });
9732
+ this.notify("diag", "aside_dropped", { text: dropped.slice(0, 80) });
9733
+ });
9734
+ return r;
9735
+ });
9736
+ }
9737
+ return this.sendTurn(content, false, rec, ext);
9738
+ });
9739
+ }
9740
+ return this.sendTurn(content, false, rec, ext);
9741
+ }
9742
+ /** Host-named user turns (send opts.id), so a later send can replace one. Bounded; a replace that arrives
9743
+ * BEFORE its target (the host's dispatch hops can reorder) leaves a pre-superseded record behind. */
9744
+ userTurns = /* @__PURE__ */ new Map();
9745
+ turnSignal;
9746
+ // the combined signal a named turn installed while it runs
9747
+ trackTurn(id) {
9748
+ let rec = this.userTurns.get(id);
9749
+ if (!rec) {
9750
+ rec = { ctl: new AbortController(), done: false, superseded: false };
9751
+ this.userTurns.set(id, rec);
9752
+ if (this.userTurns.size > 32) this.userTurns.delete(this.userTurns.keys().next().value);
9753
+ }
9754
+ return rec;
9755
+ }
9756
+ replaceTurn(id) {
9757
+ const rec = this.trackTurn(id);
9758
+ if (rec.done) {
9759
+ const drop = rec.dropped;
9760
+ rec.dropped = void 0;
9761
+ if (drop?.length) void this.enqueue(async () => {
9762
+ const t = this.voice.transcript;
9763
+ for (const m of drop) {
9764
+ const i = t.indexOf(m);
9765
+ if (i >= 0) t.splice(i, 1);
9766
+ }
9767
+ this.notify("diag", "turn_replaced", { ran: true, rolledBack: true, late: true });
8360
9768
  });
9769
+ return;
8361
9770
  }
9771
+ if (rec.superseded) return;
9772
+ rec.superseded = true;
9773
+ rec.ctl.abort();
9774
+ }
9775
+ static replacedResult(messages) {
9776
+ return { text: "", steps: 0, finishReason: "aborted", messages };
9777
+ }
9778
+ /** A turn replaced mid-run: mute whatever it still holds, and erase it from the transcript (the replacing turn
9779
+ * carries the user's full words) — unless it already dispatched work or SPOKE: the task is real, and the user
9780
+ * heard those words, so both stay visible to the replacing turn. An aborted stream never reaches the transcript,
9781
+ * so what was spoken is recorded as the replaced turn's reply. */
9782
+ dropReplacedTurn() {
9783
+ this.fabricationCut = true;
9784
+ this.reflexBuf = this.reflexBuf.slice(0, this.reflexForwarded);
9785
+ this.dropJudge();
9786
+ const spoke = this.turnSpoken.trim();
9787
+ const rolledBack = !this.turnDispatched && !spoke;
9788
+ if (rolledBack) this.voice.transcript.length = this.turnBase;
9789
+ else if (spoke) {
9790
+ const t = this.voice.transcript;
9791
+ const have = t.slice(this.turnBase).filter((m) => m.role === "assistant").map((m) => contentText(m.content)).join(" ");
9792
+ if (!have.includes(spoke)) t.push({ role: "assistant", content: spoke });
9793
+ }
9794
+ this.notify("diag", "turn_replaced", { ran: true, rolledBack, spoke: !!spoke });
9795
+ return _DuplexAgent.replacedResult(this.voice.transcript);
9796
+ }
9797
+ resumeParked() {
9798
+ if (this.spec) this.abortSpeculation();
9799
+ return this.enqueue(async () => {
9800
+ this.resetTurn();
9801
+ this.redeliverParked();
9802
+ for (const t of this.tasks.values()) if (t.status === "running" && t.deliveryParked === "barge") t.deliveryParked = void 0;
9803
+ return { text: "", steps: 0, finishReason: "stop", messages: [] };
9804
+ });
9805
+ }
9806
+ sendTurn(content, keepParked = false, rec, ext) {
8362
9807
  const spec = this.spec;
8363
9808
  if (spec?.state === "pending") {
8364
9809
  if (typeof content === "string" && speculationConfirms(spec.text, content)) {
8365
9810
  spec.state = "confirmed";
8366
9811
  spec.finalText = content;
9812
+ if (rec) {
9813
+ spec.rec = rec;
9814
+ rec.ctl.signal.addEventListener("abort", () => spec.ctl.abort(), { once: true });
9815
+ }
8367
9816
  for (const ev of spec.buf.splice(0)) this.options.host?.notify?.(ev);
8368
9817
  spec.decide("confirm");
8369
9818
  this.notify("diag", "speculation_confirmed", { text: spec.text.slice(0, 80) });
@@ -8375,12 +9824,32 @@ ${nowLine()}. Anchor every relative or time-sensitive reference \u2014 "today",
8375
9824
  await this.initMemory();
8376
9825
  this.resetTurn();
8377
9826
  this.turn++;
8378
- this.awaitTrivialRedeliver = false;
8379
- this.parkedRedeliver = [];
8380
- this.suppressParkedResume = true;
8381
- const res = await this.voice.send(content);
8382
- this.flushHeldReflexTail();
8383
- if (this.silentTurn) await this.ackIfSilent();
9827
+ if (!keepParked) {
9828
+ this.parkedRedeliver = [];
9829
+ for (const t of this.tasks.values()) if (t.deliveryParked === "barge") t.deliveryParked = "moved_on";
9830
+ }
9831
+ if (rec?.superseded) {
9832
+ this.notify("diag", "turn_replaced", { ran: false });
9833
+ return _DuplexAgent.replacedResult(this.voice.transcript);
9834
+ }
9835
+ const signal = rec && (ext ? AbortSignal.any([ext, rec.ctl.signal]) : rec.ctl.signal);
9836
+ if (signal) {
9837
+ this.voice.options.signal = signal;
9838
+ this.turnSignal = { own: signal, ext };
9839
+ }
9840
+ let res;
9841
+ try {
9842
+ res = await this.voice.send(content);
9843
+ } finally {
9844
+ if (signal) {
9845
+ this.voice.options.signal = ext;
9846
+ this.turnSignal = void 0;
9847
+ }
9848
+ if (rec) rec.done = true;
9849
+ }
9850
+ if (rec?.superseded) return this.dropReplacedTurn();
9851
+ if (rec && res.finishReason === "aborted" && !this.turnDispatched && !this.turnSpoken.trim()) rec.dropped = this.voice.transcript.slice(this.turnBase);
9852
+ await this.endTurn(res);
8384
9853
  return res;
8385
9854
  });
8386
9855
  }
@@ -8421,7 +9890,7 @@ ${nowLine()}. Anchor every relative or time-sensitive reference \u2014 "today",
8421
9890
  try {
8422
9891
  res = await this.voice.send(spec.text);
8423
9892
  } catch (e) {
8424
- log15.warn(`speculative turn failed: ${e instanceof Error ? e.message : e}`);
9893
+ log20.warn(`speculative turn failed: ${e instanceof Error ? e.message : e}`);
8425
9894
  } finally {
8426
9895
  this.voice.options.signal = prevSignal;
8427
9896
  }
@@ -8435,7 +9904,7 @@ ${nowLine()}. Anchor every relative or time-sensitive reference \u2014 "today",
8435
9904
  if (d === "abort") {
8436
9905
  if (this.voice.transcript.length > base) this.voice.transcript.length = base;
8437
9906
  this.speculativeAbortedCalls++;
8438
- log15.verbose(`speculation aborted (${this.speculativeAbortedCalls} total): "${spec.text.slice(0, 50)}"`);
9907
+ log20.verbose(`speculation aborted (${this.speculativeAbortedCalls} total): "${spec.text.slice(0, 50)}"`);
8439
9908
  if (this.spec === spec) this.spec = void 0;
8440
9909
  return res ?? empty;
8441
9910
  }
@@ -8447,8 +9916,9 @@ ${nowLine()}. Anchor every relative or time-sensitive reference \u2014 "today",
8447
9916
  }
8448
9917
  }
8449
9918
  this.spec = void 0;
8450
- this.flushHeldReflexTail();
8451
- if (this.silentTurn) await this.ackIfSilent();
9919
+ if (spec.rec) spec.rec.done = true;
9920
+ if (spec.rec?.superseded) return this.dropReplacedTurn();
9921
+ await this.endTurn(res);
8452
9922
  return res ?? empty;
8453
9923
  });
8454
9924
  }
@@ -8477,37 +9947,125 @@ ${nowLine()}. Anchor every relative or time-sensitive reference \u2014 "today",
8477
9947
  * superseded topic never talks over the new one (the debt-after-jokes regression). Tasks keep running and
8478
9948
  * still fold their result into the transcript — recoverable, just not spoken. Returns parked ids (logging).
8479
9949
  * Does NOT cancel. Two callers, two shapes:
8480
- * - BARGE (default): the user interrupted AUDIO. Park EVERY running task (including this turn's) — the
8481
- * worker they cut off must stop, whatever turn launched it — and re-arm trivial-barge auto-resume so a
8482
- * following "go on" can bring the parked delivery back.
8483
- * - SUPERSEDE (`priorTurnsOnly`, `reenableResume:false`, from dispatch()): a new dispatch redirected to
8484
- * fresh work. Park only PRIOR-turn tasks so parallel siblings dispatched in the SAME turn don't silence
8485
- * each other, and keep them suppressed (the user moved on — don't resurface on a stray "go on"). */
8486
- parkInFlightDeliveries(reenableResume = true, priorTurnsOnly = false) {
8487
- if (reenableResume) this.suppressParkedResume = false;
9950
+ * - BARGE (default): the user interrupted AUDIO. Park EVERY live running task (including this turn's) — the
9951
+ * worker they cut off must stop, whatever turn launched it — as 'barge', so a following "go on" can bring
9952
+ * it back. A task the user already moved on from stays moved on: a barge never revives it.
9953
+ * - SUPERSEDE (`priorTurnsOnly`, `barge:false`, from dispatch()): a new dispatch redirected to fresh work.
9954
+ * Park only PRIOR-turn live tasks so parallel siblings dispatched in the SAME turn don't silence each other,
9955
+ * as 'moved_on' (don't resurface on a stray "go on"). */
9956
+ parkInFlightDeliveries(barge = true, priorTurnsOnly = false) {
9957
+ if (barge) {
9958
+ this.bargeMark = this.parkedRedeliver.length;
9959
+ this.reflexCut = false;
9960
+ this.offerOpen = false;
9961
+ }
8488
9962
  const parked = [];
8489
9963
  for (const rec of this.tasks.values())
8490
9964
  if (rec.status === "running" && !rec.deliveryParked && (!priorTurnsOnly || rec.createdTurn < this.turn)) {
8491
- rec.deliveryParked = true;
9965
+ rec.deliveryParked = barge ? "barge" : "moved_on";
8492
9966
  parked.push(rec.id);
8493
9967
  }
8494
9968
  return parked;
8495
9969
  }
8496
- /** True while there is a parked delivery to potentially resume: either one already settled (queued) or
8497
- * one still running that was parked by a barge. */
9970
+ /** A barge-in cut a worker delivery the VOICE ENGINE was still speaking — the queued utterances it dropped plus the
9971
+ * unheard rest of the one playing (cli splitCut). It joins the parked delivery AHEAD of whatever parked tasks
9972
+ * produced since the barge (it was voiced first); the user's next line runs the barge-intent judge — resume it,
9973
+ * answer an aside and carry it on, or drop it on a steer/stop. `heard` = the cut utterance's part the user DID hear
9974
+ * (the aside note tells the reflex what was heard vs missed).
9975
+ * Live 20260923-125143: "Oh, that— that's new." cut an iOS-news delivery and its 6 remaining utterances vanished.
9976
+ * `reflexCut` = the barge cut a REFLEX reply (the host's cut had a reply): a "go on" then runs the reflex (see send).
9977
+ * A cut spoken by a task the user moved on from (or whose outcome went to an integration turn) is not parked. */
9978
+ parkCutDelivery(text, reflexCut = false, heard = "") {
9979
+ this.reflexCut = reflexCut;
9980
+ const t = text.trim();
9981
+ if (!t) return;
9982
+ if (this.offer.text && this.offer.text.endsWith(t)) {
9983
+ this.offerOpen = !reflexCut;
9984
+ return;
9985
+ }
9986
+ if (t.split(SENTENCE_SPLIT).every(isQuestion)) return;
9987
+ const first = t.split(/(?<=[.!?])\s/)[0];
9988
+ const task = [...this.voicedBy].reverse().find(([seg]) => t.includes(seg) || first.length >= 16 && seg.includes(first))?.[1];
9989
+ if (task && this.tasks.get(task)?.deliveryParked === "moved_on") return;
9990
+ this.parkedRedeliver.splice(Math.min(this.bargeMark, this.parkedRedeliver.length), 0, { text: t, task, ...heard ? { heard } : {} });
9991
+ this.notify("diag", "delivery_parked", { chars: t.length });
9992
+ }
9993
+ /** One spoken segment of a task's delivery — the ONLY path a worker's speech takes (streamed segments, the settle
9994
+ * tail, the no-spoken fallback). Live → the voice queue; barge-parked → the parked delivery, in order; superseded
9995
+ * (the user moved on) → nowhere (the result still folds into the transcript at settle). */
9996
+ deliverSegment(rec, seg) {
9997
+ if (!seg.trim()) return;
9998
+ if (rec) rec.spokenText = (rec.spokenText ? rec.spokenText + " " : "") + seg;
9999
+ if (!rec?.deliveryParked) {
10000
+ const [say, rest] = rec ? this.budgetSplit(rec, seg) : [seg, ""];
10001
+ if (say) {
10002
+ this.offerOpen = false;
10003
+ this.options.host?.notify?.({ kind: "speak_utterance", message: say });
10004
+ if (rec) {
10005
+ this.voicedBy.set(say.trim(), rec.id);
10006
+ if (this.voicedBy.size > 64) this.voicedBy.delete(this.voicedBy.keys().next().value);
10007
+ }
10008
+ }
10009
+ if (!rest || !rec) return;
10010
+ rec.deliveryParked = "barge";
10011
+ this.parkedRedeliver.push({ text: rest, task: rec.id });
10012
+ if (this.options.spokenOffer) {
10013
+ this.offer = { text: rec.capOffer = this.options.spokenOffer, task: rec.id };
10014
+ this.offerOpen = true;
10015
+ this.options.host?.notify?.({ kind: "speak_utterance", message: this.offer.text });
10016
+ }
10017
+ this.notify("diag", "delivery_budget", { task: rec.id, spoke: rec.voicedChars, parked: rest.length });
10018
+ } else if (rec.deliveryParked === "barge") this.parkedRedeliver.push({ text: seg, task: rec.id });
10019
+ }
10020
+ /** Split a delivery segment at the spoken budget (whole sentences; the first sentence always speaks): what to voice
10021
+ * now, and the rest to park. Marks the budget spent on a cut, so a resumed delivery runs on unbudgeted. */
10022
+ budgetSplit(rec, seg) {
10023
+ const cap = this.options.spokenBudgetChars;
10024
+ if (!cap || rec.budgetSpent) return [seg, ""];
10025
+ const sents = seg.trim().split(SENTENCE_SPLIT);
10026
+ let used = rec.voicedChars ?? 0, n = 0;
10027
+ while (n < sents.length && (!used || used + sents[n].length <= cap)) used += sents[n++].length + 1;
10028
+ const over = sents.slice(n);
10029
+ if (!over.length || over.length === 1 && isQuestion(over[0]) && over[0].length <= OFFER_OVERRUN && used <= cap) {
10030
+ rec.voicedChars = used + (over[0] ? over[0].length + 1 : 0);
10031
+ return [seg, ""];
10032
+ }
10033
+ rec.voicedChars = used;
10034
+ rec.budgetSpent = true;
10035
+ return [sents.slice(0, n).join(" "), sents.slice(n).join(" ")];
10036
+ }
10037
+ /** The cap offer last spoken and the task whose rest it offers — a barge over it is not a delivery to park
10038
+ * (parkCutDelivery). */
10039
+ offer = { text: "", task: "" };
10040
+ /** The cap offer was the last thing voiced (no delivery or turn has spoken since) — a plain yes accepts it (send). */
10041
+ offerOpen = false;
10042
+ /** The task's outcome now belongs to its integration turn (failed / stopped early): take back its parked pieces and
10043
+ * keep it from being revived — a "go on" must not replay raw segments of a result the reflex is about to explain. */
10044
+ dropParked(rec) {
10045
+ this.parkedRedeliver = this.parkedRedeliver.filter((p) => p.task !== rec.id);
10046
+ if (rec.deliveryParked) rec.deliveryParked = "moved_on";
10047
+ }
10048
+ /** True while there is a parked delivery the next line may resume: unheard text already parked, or a barge-parked
10049
+ * task still running (one the user moved on from does not count). */
8498
10050
  hasParkedDelivery() {
8499
- return this.parkedRedeliver.length > 0 || [...this.tasks.values()].some((t) => t.status === "running" && t.deliveryParked);
8500
- }
8501
- /** Speak any settled-and-queued parked delivery now (trivial-barge resume). Returns false if nothing was
8502
- * queued yet (the caller then arms awaitTrivialRedeliver so the task resumes the instant it settles). */
10051
+ return this.parkedRedeliver.length > 0 || [...this.tasks.values()].some((t) => t.status === "running" && t.deliveryParked === "barge");
10052
+ }
10053
+ /** The note for an aside over a parked delivery: the same reply-interruption note a cut reflex reply gets
10054
+ * (interruptionNote), over the delivery as it was being said — the cut utterance's heard part, then every unheard
10055
+ * piece. Nothing parked yet (the task is still working) → its result simply follows the answer. */
10056
+ asideNote(told) {
10057
+ const rest = told.map((p) => p.text).join(" ").trim();
10058
+ 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.]";
10059
+ const heard = told[0]?.heard ?? "";
10060
+ return interruptionNote({ full: heard + rest, heard }, "aside");
10061
+ }
10062
+ /** Speak the parked delivery now (resume), in order, and clear it. */
8503
10063
  redeliverParked() {
8504
- if (!this.parkedRedeliver.length) return false;
8505
- const text = this.parkedRedeliver.splice(0).join(" ").trim();
8506
- if (text) {
8507
- this.options.host?.notify?.({ kind: "speak_utterance", message: text });
8508
- this.notify("diag", "parked_redelivered", { chars: text.length });
8509
- }
8510
- return true;
10064
+ const text = this.parkedRedeliver.splice(0).map((p) => p.text).join(" ").trim();
10065
+ if (!text) return;
10066
+ this.offerOpen = false;
10067
+ this.options.host?.notify?.({ kind: "speak_utterance", message: text });
10068
+ this.notify("diag", "parked_redelivered", { chars: text.length });
8511
10069
  }
8512
10070
  /** Resolve when all queued voice turns AND all in-flight worker tasks have settled (tests, graceful shutdown). */
8513
10071
  async idle() {
@@ -8563,9 +10121,8 @@ ${nowLine()}. Anchor every relative or time-sensitive reference \u2014 "today",
8563
10121
  const failed = events.find((e) => /^\[task\b[^\]\n]*\bfailed\b/i.test(e));
8564
10122
  this.resetTurn();
8565
10123
  this.turnFollowUp = nonCleanTurn;
8566
- await this.voice.send(events.join("\n"));
8567
- this.flushHeldReflexTail();
8568
- if (this.silentTurn) await this.ackIfSilent(failed ? "Sorry, that didn't work \u2014 the task failed." : void 0);
10124
+ const res = await this.voice.send(events.join("\n"));
10125
+ await this.endTurn(res, failed ? "Sorry, that didn't work \u2014 the task failed." : void 0);
8569
10126
  this.notify("revoice_done", "");
8570
10127
  });
8571
10128
  }
@@ -8573,13 +10130,14 @@ ${nowLine()}. Anchor every relative or time-sensitive reference \u2014 "today",
8573
10130
  * Act briefs get a self-verify footer — the worker's report is trusted without review, so it
8574
10131
  * must check its own work before reporting (nearly free under prompt caching; measured honest:
8575
10132
  * it does NOT fix one-shot logic bugs — see mind/10). Think tasks are pure reasoning — no footer. */
10133
+ /** Static text snapshot of the last `turns` user/assistant messages (`role: text` lines). */
10134
+ recentExcerpt(turns = this.options.excerptTurns) {
10135
+ 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");
10136
+ }
8576
10137
  buildBrief(brief, tier = "act", deliver = true) {
8577
- 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");
10138
+ const recent = this.recentExcerpt();
8578
10139
  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." : "";
8579
- const deliverContract = deliver ? `
8580
-
8581
- ## DELIVER (spoken delivery)
8582
- 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." : "") : "";
10140
+ 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." : "") : "";
8583
10141
  return `${nowLine()}.
8584
10142
 
8585
10143
  ` + (recent ? `${brief}
@@ -8625,12 +10183,7 @@ ${recent}` : brief) + verify + deliverContract;
8625
10183
  return a || "(no answer from the user \u2014 use your best judgment and note the assumption)";
8626
10184
  };
8627
10185
  const splitter = new SpokenSplitter();
8628
- const speak = (seg) => {
8629
- if (!seg) return;
8630
- const r = this.tasks.get(id);
8631
- if (r) r.spokenText = (r.spokenText ? r.spokenText + " " : "") + seg;
8632
- if (!r?.deliveryParked) o.host?.notify?.({ kind: "speak_utterance", message: seg });
8633
- };
10186
+ const speak = (seg) => this.deliverSegment(this.tasks.get(id), seg);
8634
10187
  const coalescer = new SentenceCoalescer();
8635
10188
  const feedSpoken = (s) => {
8636
10189
  const ready = coalescer.feed(s);
@@ -8644,6 +10197,7 @@ ${recent}` : brief) + verify + deliverContract;
8644
10197
  if (ev?.kind === "text_delta" && typeof ev.message === "string") {
8645
10198
  const { spoken, detail } = splitter.feed(ev.message);
8646
10199
  feedSpoken(spoken);
10200
+ if (!splitter.inSpoken) flushSpoken();
8647
10201
  if (detail.trim()) pushTail(detail.trim());
8648
10202
  return;
8649
10203
  }
@@ -8700,7 +10254,7 @@ Another agent just implemented the above. Independently check the CURRENT state
8700
10254
  this.notify("task_verify", `task ${id}: verifying`, { id });
8701
10255
  const cres = await new Agent(checkerOpts).run(checkBrief);
8702
10256
  if (cres.finishReason !== "stop") {
8703
- log15.warn(`task ${id}: verify inconclusive (${cres.finishReason})`);
10257
+ log20.warn(`task ${id}: verify inconclusive (${cres.finishReason})`);
8704
10258
  this.notify("task_verify", `task ${id}: verify inconclusive (${cres.finishReason})`, { id, finishReason: cres.finishReason });
8705
10259
  }
8706
10260
  const sum = (a = 0, b = 0) => a + b;
@@ -8731,7 +10285,7 @@ Another agent just implemented the above. Independently check the CURRENT state
8731
10285
  const due = () => {
8732
10286
  if (this.pendingAsks.size) return void 0;
8733
10287
  const rec = this.tasks.get(id);
8734
- return rec && rec.status === "running" && Date.now() - lastAt >= this.options.progressIntervalMs ? rec : void 0;
10288
+ return rec && rec.status === "running" && !rec.deliveryParked && Date.now() - lastAt >= this.options.progressIntervalMs ? rec : void 0;
8735
10289
  };
8736
10290
  const emit = (rec, line, call) => {
8737
10291
  lastAt = Date.now();
@@ -8836,7 +10390,7 @@ Another agent just implemented the above. Independently check the CURRENT state
8836
10390
  rec.status = "done";
8837
10391
  rec.result = res.text;
8838
10392
  const incomplete = res.finishReason !== "stop" && res.finishReason !== "empty";
8839
- log15.verbose(`task ${id} done (${res.steps} steps${incomplete ? `, INCOMPLETE: ${res.finishReason}` : ""})`);
10393
+ log20.verbose(`task ${id} done (${res.steps} steps${incomplete ? `, INCOMPLETE: ${res.finishReason}` : ""})`);
8840
10394
  this.notify("task_done", `task ${id} (${rec.label}) completed`, {
8841
10395
  id,
8842
10396
  text: res.text,
@@ -8848,46 +10402,39 @@ Another agent just implemented the above. Independently check the CURRENT state
8848
10402
  });
8849
10403
  if (incomplete) {
8850
10404
  if (this.foldIfSuperseded(rec, res.text)) return;
10405
+ this.dropParked(rec);
8851
10406
  return this.queueRevoice(this.integrationPrompt(rec, "incomplete", res.text, res.finishReason), true);
8852
10407
  }
8853
10408
  const tail = rec.splitter?.flush();
8854
- if (tail?.spoken && !rec.deliveryParked) this.options.host?.notify?.({ kind: "speak_utterance", message: tail.spoken });
8855
- if (res.text.trim()) this.voice.transcript.push({ role: "assistant", content: res.text });
8856
- if (rec.deliveryParked && !this.suppressParkedResume) {
8857
- const gist = (rec.spokenText ?? "").trim() || res.text.trim();
8858
- if (gist) {
8859
- if (this.awaitTrivialRedeliver) {
8860
- this.awaitTrivialRedeliver = false;
8861
- this.options.host?.notify?.({ kind: "speak_utterance", message: gist });
8862
- this.notify("diag", "parked_redelivered", { chars: gist.length });
8863
- } else this.parkedRedeliver.push(gist);
8864
- }
8865
- }
8866
- if (!rec.splitter?.spokeAny && res.text.trim() && !rec.deliveryParked)
8867
- this.options.host?.notify?.({ kind: "speak_utterance", message: res.text });
10409
+ if (tail?.spoken) this.deliverSegment(rec, tail.spoken);
10410
+ if (!rec.splitter?.spokeAny && res.text.trim()) this.deliverSegment(rec, res.text);
10411
+ if (res.text.trim()) this.voice.transcript.push({ role: "assistant", content: res.text + (rec.capOffer ? `
10412
+
10413
+ (Said aloud in place of the rest of the spoken summary: "${rec.capOffer}")` : "") });
8868
10414
  }
8869
10415
  onWorkerFailed(id, err2) {
8870
10416
  this.failTask(this.tasks.get(id), err2 instanceof Error ? err2.message : String(err2));
8871
10417
  }
8872
- /** A delivery the user has SUPERSEDED — parked AND moved on (suppressParkedResume, set by a redirect) —
10418
+ /** A delivery the user has SUPERSEDED — moved on from (deliveryParked 'moved_on', set by a redirect) —
8873
10419
  * must not hijack the voice when it settles late (partial or failed): that would talk over the new topic
8874
10420
  * and, worse, auto-escalate work the user already abandoned. Fold a recoverable note into the transcript
8875
10421
  * instead (the reflex can surface it if asked) and report true so the caller skips queueRevoice. A merely
8876
- * barge-parked task (suppressParkedResume false — a "go on" may still resume it) is NOT superseded: it
10422
+ * barge-parked task ('barge' — a "go on" may still resume it) is NOT superseded: it
8877
10423
  * keeps the normal surfacing path so a failure the user still cares about is not swallowed. */
8878
10424
  foldIfSuperseded(rec, note) {
8879
- if (!(rec.deliveryParked && this.suppressParkedResume)) return false;
10425
+ if (rec.deliveryParked !== "moved_on") return false;
8880
10426
  if (note.trim()) this.voice.transcript.push({ role: "assistant", content: note });
8881
- log15.verbose(`task ${rec.id} settled but superseded (${rec.status}) \u2014 folded into transcript, not voiced`);
10427
+ log20.verbose(`task ${rec.id} settled but superseded (${rec.status}) \u2014 folded into transcript, not voiced`);
8882
10428
  return true;
8883
10429
  }
8884
10430
  failTask(rec, msg) {
8885
10431
  this.dropAsk(rec.id);
8886
10432
  rec.status = "error";
8887
10433
  rec.result = msg;
8888
- log15.warn(`task ${rec.id} failed: ${msg}`);
10434
+ log20.warn(`task ${rec.id} failed: ${msg}`);
8889
10435
  this.notify("task_error", `task ${rec.id} (${rec.label}) failed: ${msg}`);
8890
10436
  if (this.foldIfSuperseded(rec, `The "${rec.label}" task failed: ${msg}`)) return;
10437
+ this.dropParked(rec);
8891
10438
  this.queueRevoice(this.integrationPrompt(rec, "error", msg, "error"), true);
8892
10439
  }
8893
10440
  // --- voice tools (closures over this instance) ---
@@ -8916,10 +10463,18 @@ Another agent just implemented the above. Independently check the CURRENT state
8916
10463
  this.notify("task_started", `task ${id} (${lbl}) started`, { id, brief, tier });
8917
10464
  return id;
8918
10465
  }
10466
+ /** Tool result for a dispatch. The task is ALREADY RUNNING — live, the terse "Acknowledge briefly" drew
10467
+ * "Did you want me to look up today's news?" right after dispatching exactly that (session 20260923-101910
10468
+ * msg 18). Also truthful about delivery: a clean result is spoken by the worker itself and lands in
10469
+ * context (TaskStatus returns it); only failures come back as a [task … failed] event. */
10470
+ dispatchResult(id, verb) {
10471
+ const after = `The worker speaks its result to the user itself when done (TaskStatus ${id} returns it).`;
10472
+ 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}`;
10473
+ }
8919
10474
  actTool() {
8920
10475
  return {
8921
10476
  name: "Act",
8922
- 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).',
10477
+ 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).',
8923
10478
  parameters: {
8924
10479
  type: "object",
8925
10480
  required: ["brief"],
@@ -8934,7 +10489,7 @@ Another agent just implemented the above. Independently check the CURRENT state
8934
10489
  this.turnBriefs.add(String(brief ?? ""));
8935
10490
  this.voice.options.toolChoice = "none";
8936
10491
  const id = await this.dispatch(String(brief ?? ""), "act", label ? String(label) : void 0, this.turnFollowUp);
8937
- 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.`;
10492
+ return this.dispatchResult(id, "Acting on");
8938
10493
  }
8939
10494
  };
8940
10495
  }
@@ -8956,7 +10511,7 @@ Another agent just implemented the above. Independently check the CURRENT state
8956
10511
  this.turnBriefs.add(String(brief ?? ""));
8957
10512
  this.voice.options.toolChoice = "none";
8958
10513
  const id = await this.dispatch(String(brief ?? ""), "think", label ? String(label) : void 0, this.turnFollowUp);
8959
- 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.`;
10514
+ return this.dispatchResult(id, "Thinking on");
8960
10515
  }
8961
10516
  };
8962
10517
  }
@@ -8968,7 +10523,13 @@ Another agent just implemented the above. Independently check the CURRENT state
8968
10523
  run: async ({ id }) => {
8969
10524
  const list = id ? [this.tasks.get(String(id))].filter(Boolean) : [...this.tasks.values()];
8970
10525
  if (!list.length) return id ? `No task '${id}'.` : "No background tasks.";
8971
- return list.map((t) => `${t.id} (${t.label}): ${t.status}`).join("\n");
10526
+ const cap = id ? 1500 : 300;
10527
+ const clip = (x) => x.length > cap ? `${x.slice(0, cap)}\u2026 (truncated)` : x;
10528
+ return list.map((t) => {
10529
+ const head = `${t.id} (${t.label}): ${t.status}`;
10530
+ const body = (t.status === "done" ? t.spokenText?.trim() || t.result?.trim() : t.status === "error" ? t.result?.trim() : "") ?? "";
10531
+ return body ? `${head}. ${t.status === "error" ? "Error" : "Result"}: ${clip(body)}` : head;
10532
+ }).join("\n");
8972
10533
  }
8973
10534
  };
8974
10535
  }
@@ -8980,18 +10541,20 @@ Another agent just implemented the above. Independently check the CURRENT state
8980
10541
  const kinds = [.../* @__PURE__ */ new Set(["time", "branch", "ls", "file", "capabilities", ...Object.keys(this.options.quickLook ?? {})])];
8981
10542
  return {
8982
10543
  name: "QuickLook",
8983
- description: `Instant read-only lookup \u2014 one of: ${kinds.join(", ")}. For trivial facts only; anything needing search, commands, or reasoning goes through Act.`,
10544
+ 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.",
8984
10545
  parameters: {
8985
10546
  type: "object",
8986
10547
  required: ["what"],
8987
10548
  properties: {
8988
10549
  what: { type: "string", enum: kinds, description: "what to look up" },
8989
- path: { type: "string", description: "for ls/file: the path to look at" }
10550
+ path: { type: "string", description: 'for ls/file: the path to look at; for sessions: a session id (or "previous")' }
8990
10551
  }
8991
10552
  },
8992
10553
  run: async ({ what, path }) => {
8993
10554
  const fs = this.options.fs;
8994
10555
  try {
10556
+ const sessions = this.options.quickLook?.sessions;
10557
+ if (sessions && (what === "file" || what === "ls") && SESSION_ID_RE.test(String(path ?? "").trim())) return await sessions(String(path).trim());
8995
10558
  const over = this.options.quickLook?.[String(what)];
8996
10559
  if (over) return await over(path ? String(path) : void 0);
8997
10560
  switch (String(what)) {
@@ -9286,7 +10849,7 @@ import { MemFilesystem as MemFilesystem3, IndexedDbFilesystem, CommandExecutor a
9286
10849
  init_logging();
9287
10850
  import { spawn } from "child_process";
9288
10851
  import { createHash } from "crypto";
9289
- var log16 = forComponent("mcp");
10852
+ var log21 = forComponent("mcp");
9290
10853
  var PROTOCOL_VERSION = "2025-06-18";
9291
10854
  var DEFAULT_TIMEOUT_MS = 3e4;
9292
10855
  var DEFAULT_CALL_TIMEOUT_MS = 30 * 6e4;
@@ -9316,7 +10879,7 @@ var StdioTransport = class {
9316
10879
  proc.stdout.setEncoding("utf8");
9317
10880
  proc.stdout.on("data", (chunk) => this.onData(chunk));
9318
10881
  proc.stderr.setEncoding("utf8");
9319
- proc.stderr.on("data", (chunk) => log16.debug(`[${command}] stderr:`, chunk.trimEnd()));
10882
+ proc.stderr.on("data", (chunk) => log21.debug(`[${command}] stderr:`, chunk.trimEnd()));
9320
10883
  proc.on("exit", (code) => this.failAll(new Error(`MCP server "${command}" exited (code ${code})`)));
9321
10884
  proc.on("error", (e) => this.failAll(e instanceof Error ? e : new Error(String(e))));
9322
10885
  }
@@ -9330,7 +10893,7 @@ var StdioTransport = class {
9330
10893
  try {
9331
10894
  this.dispatch(JSON.parse(line));
9332
10895
  } catch (e) {
9333
- log16.debug("dropping non-JSON line from MCP server:", line, e);
10896
+ log21.debug("dropping non-JSON line from MCP server:", line, e);
9334
10897
  }
9335
10898
  }
9336
10899
  }
@@ -9340,7 +10903,7 @@ var StdioTransport = class {
9340
10903
  try {
9341
10904
  p2?.onProgress?.(msg.params ?? {});
9342
10905
  } catch (e) {
9343
- log16.debug("onProgress handler threw", e);
10906
+ log21.debug("onProgress handler threw", e);
9344
10907
  }
9345
10908
  return;
9346
10909
  }
@@ -9378,7 +10941,7 @@ var StdioTransport = class {
9378
10941
  const onAbort = () => {
9379
10942
  if (!this.pending.delete(id)) return;
9380
10943
  clearTimeout(timer);
9381
- this.notify("notifications/cancelled", { requestId: id, reason: String(signal?.reason?.message ?? "cancelled") }).catch((e) => log16.debug("cancel notify failed", e));
10944
+ this.notify("notifications/cancelled", { requestId: id, reason: String(signal?.reason?.message ?? "cancelled") }).catch((e) => log21.debug("cancel notify failed", e));
9382
10945
  reject(abortError(signal));
9383
10946
  };
9384
10947
  const done = () => signal?.removeEventListener("abort", onAbort);
@@ -9402,7 +10965,7 @@ var StdioTransport = class {
9402
10965
  try {
9403
10966
  this.proc?.stdin?.end();
9404
10967
  } catch (e) {
9405
- log16.debug("stdin end failed", e);
10968
+ log21.debug("stdin end failed", e);
9406
10969
  }
9407
10970
  this.proc?.kill();
9408
10971
  }
@@ -9437,7 +11000,7 @@ var HttpTransport = class {
9437
11000
  return await this.post({ jsonrpc: "2.0", id, method, params: opts.onProgress ? withProgressToken(params, id) : params }, false, signal, opts.onProgress);
9438
11001
  } catch (e) {
9439
11002
  if (opts.signal?.aborted) {
9440
- void this.notify("notifications/cancelled", { requestId: id, reason: String(opts.signal.reason?.message ?? "cancelled") }).catch((err2) => log16.debug("cancel notify failed", err2));
11003
+ void this.notify("notifications/cancelled", { requestId: id, reason: String(opts.signal.reason?.message ?? "cancelled") }).catch((err2) => log21.debug("cancel notify failed", err2));
9441
11004
  throw abortError(opts.signal);
9442
11005
  }
9443
11006
  throw e;
@@ -9480,7 +11043,7 @@ function sseMessage(line) {
9480
11043
  try {
9481
11044
  return JSON.parse(trimmed.slice(5).trim());
9482
11045
  } catch (e) {
9483
- log16.debug("skipping unparseable SSE data line", e);
11046
+ log21.debug("skipping unparseable SSE data line", e);
9484
11047
  return void 0;
9485
11048
  }
9486
11049
  }
@@ -9490,7 +11053,7 @@ async function readSseResponse(resp, id, onProgress) {
9490
11053
  try {
9491
11054
  onProgress?.(obj.params ?? {});
9492
11055
  } catch (e) {
9493
- log16.debug("onProgress handler threw", e);
11056
+ log21.debug("onProgress handler threw", e);
9494
11057
  }
9495
11058
  return void 0;
9496
11059
  }
@@ -9589,7 +11152,7 @@ async function mountWithDeadline(name, cfg, mountTimeoutMs) {
9589
11152
  return { name, client, tools, specs, serverInfo: init?.serverInfo, config: cfg };
9590
11153
  })(), mountTimeoutMs, name);
9591
11154
  } catch (e) {
9592
- await client.close().catch((err2) => log16.debug(`close after failed mount of "${name}": ${err2}`));
11155
+ await client.close().catch((err2) => log21.debug(`close after failed mount of "${name}": ${err2}`));
9593
11156
  throw e;
9594
11157
  }
9595
11158
  }
@@ -9600,15 +11163,15 @@ function validEntries(servers) {
9600
11163
  return Object.entries(servers).filter(([name, cfg]) => {
9601
11164
  if (!cfg || cfg.disabled) return false;
9602
11165
  if (!cfg.command && !cfg.url) {
9603
- log16.warn(`MCP server "${name}" needs a command (stdio) or url (http) \u2014 skipping`);
11166
+ log21.warn(`MCP server "${name}" needs a command (stdio) or url (http) \u2014 skipping`);
9604
11167
  return false;
9605
11168
  }
9606
11169
  return true;
9607
11170
  });
9608
11171
  }
9609
11172
  function logMountFailure(name, e) {
9610
- if (e instanceof McpAuthError) log16.warn(`MCP "${name}" needs-auth: HTTP ${e.status} \u2014 set bearerToken or headers in its config; skipping`);
9611
- else log16.error(`MCP server "${name}" failed to mount: ${e?.message ?? e}`);
11173
+ if (e instanceof McpAuthError) log21.warn(`MCP "${name}" needs-auth: HTTP ${e.status} \u2014 set bearerToken or headers in its config; skipping`);
11174
+ else log21.error(`MCP server "${name}" failed to mount: ${e?.message ?? e}`);
9612
11175
  }
9613
11176
  async function mountMcpServers(servers = {}, opts = {}) {
9614
11177
  const entries = validEntries(servers);
@@ -9618,7 +11181,7 @@ async function mountMcpServers(servers = {}, opts = {}) {
9618
11181
  const name = entries[i][0];
9619
11182
  if (r.status === "fulfilled") {
9620
11183
  out.push(r.value);
9621
- log16.debug(`MCP "${name}" mounted \u2014 ${r.value.tools.length} tool(s)${r.value.serverInfo?.name ? ` from ${r.value.serverInfo.name}` : ""}`);
11184
+ log21.debug(`MCP "${name}" mounted \u2014 ${r.value.tools.length} tool(s)${r.value.serverInfo?.name ? ` from ${r.value.serverInfo.name}` : ""}`);
9622
11185
  } else logMountFailure(name, r.reason);
9623
11186
  });
9624
11187
  return out;
@@ -9658,7 +11221,7 @@ var McpPool = class {
9658
11221
  const prev = this.warm.get(key);
9659
11222
  if (prev) {
9660
11223
  clearTimeout(prev.timer);
9661
- if (prev.client !== client) void prev.client.close().catch((err2) => log16.debug(`warm-pool replace close failed: ${err2}`));
11224
+ if (prev.client !== client) void prev.client.close().catch((err2) => log21.debug(`warm-pool replace close failed: ${err2}`));
9662
11225
  }
9663
11226
  const e = { client, timer: void 0 };
9664
11227
  this.warm.set(key, e);
@@ -9675,7 +11238,7 @@ var McpPool = class {
9675
11238
  const e = this.warm.get(key);
9676
11239
  if (!e) return;
9677
11240
  this.warm.delete(key);
9678
- await e.client.close().catch((err2) => log16.debug(`warm-pool evict close failed: ${err2}`));
11241
+ await e.client.close().catch((err2) => log21.debug(`warm-pool evict close failed: ${err2}`));
9679
11242
  }
9680
11243
  async closeAll() {
9681
11244
  for (const e of this.warm.values()) {
@@ -9893,7 +11456,7 @@ init_tools_shell();
9893
11456
  // src/tools.notify.ts
9894
11457
  init_logging();
9895
11458
  import { execFile } from "child_process";
9896
- var log18 = forComponent("notify");
11459
+ var log23 = forComponent("notify");
9897
11460
  function makeNotifyTool(opts = {}) {
9898
11461
  const platform2 = opts.platform ?? process.platform;
9899
11462
  const run = opts.exec ?? execFile;
@@ -9917,7 +11480,7 @@ function makeNotifyTool(opts = {}) {
9917
11480
  return new Promise((resolve5) => {
9918
11481
  run(argv[0], argv[1], { timeout: 5e3 }, (e) => {
9919
11482
  if (e) {
9920
- log18.debug("notification failed", e);
11483
+ log23.debug("notification failed", e);
9921
11484
  resolve5(`Notification failed: ${e.message}`);
9922
11485
  } else resolve5("Notification shown.");
9923
11486
  });
@@ -9933,7 +11496,7 @@ import { BodDB as BodDB2 } from "@bod.ee/db";
9933
11496
  init_logging();
9934
11497
  import { existsSync as existsSync2, readFileSync as readFileSync4 } from "fs";
9935
11498
  import { homedir } from "os";
9936
- var log19 = forComponent("cli-util");
11499
+ var log24 = forComponent("cli-util");
9937
11500
  function dotDirs(base, sub, opts = {}) {
9938
11501
  const home = opts.home ?? homedir();
9939
11502
  const dirs = [`${base}/.agent/${sub}`, `${base}/.claude/${sub}`, `${home}/.agent/${sub}`, `${home}/.claude/${sub}`];
@@ -9950,7 +11513,7 @@ function parseJson(text, fallback, what = "json") {
9950
11513
  try {
9951
11514
  return JSON.parse(text);
9952
11515
  } catch (e) {
9953
- log19.debug(`parseJson(${what}) failed: ${e.message}`);
11516
+ log24.debug(`parseJson(${what}) failed: ${e.message}`);
9954
11517
  return fallback;
9955
11518
  }
9956
11519
  }
@@ -9960,7 +11523,7 @@ function readJsonFile(path, fallback) {
9960
11523
  try {
9961
11524
  text = readFileSync4(path, "utf8");
9962
11525
  } catch (e) {
9963
- log19.debug(`readJsonFile(${path}) unreadable: ${e.message}`);
11526
+ log24.debug(`readJsonFile(${path}) unreadable: ${e.message}`);
9964
11527
  return fallback;
9965
11528
  }
9966
11529
  return parseJson(text, fallback, path);
@@ -10259,7 +11822,7 @@ import { existsSync as existsSync4, mkdirSync as mkdirSync5, readFileSync as rea
10259
11822
  import { homedir as homedir3 } from "os";
10260
11823
  import { dirname as dirname3, join as join6 } from "path";
10261
11824
  import { fileURLToPath } from "url";
10262
- var log20 = forComponent("VoiceIO");
11825
+ var log25 = forComponent("VoiceIO");
10263
11826
  var now3 = () => performance.now();
10264
11827
  var Player = class {
10265
11828
  proc = null;
@@ -10273,7 +11836,7 @@ var Player = class {
10273
11836
  ["-loglevel", "quiet", "-nodisp", "-fflags", "nobuffer", "-flags", "low_delay", "-probesize", "32", "-f", "s16le", "-ar", String(TTS_SAMPLE_RATE), "-ch_layout", "mono", "-i", "-"],
10274
11837
  { stdio: ["pipe", "ignore", "ignore"] }
10275
11838
  );
10276
- this.proc.on("error", (e) => log20.warn(`ffplay error: ${e.message}`));
11839
+ this.proc.on("error", (e) => log25.warn(`ffplay error: ${e.message}`));
10277
11840
  this.proc.stdin.on("error", () => {
10278
11841
  });
10279
11842
  this.bytesWritten = 0;
@@ -10311,7 +11874,7 @@ function detectFfmpegMic() {
10311
11874
  const devices = [...audio.matchAll(/\[(\d+)\] (.+)/g)].map(([, idx, name]) => ({ idx, name: name.trim() }));
10312
11875
  const mic = devices.find((d) => /microphone|built-in/i.test(d.name) && !/teams|blackhole|loopback/i.test(d.name)) ?? devices[0];
10313
11876
  if (!mic) throw new Error("no audio input device found");
10314
- log20.debug(`ffmpeg mic: [${mic.idx}] ${mic.name}`);
11877
+ log25.debug(`ffmpeg mic: [${mic.idx}] ${mic.name}`);
10315
11878
  return `:${mic.idx}`;
10316
11879
  }
10317
11880
  function detectedInputDevice() {
@@ -10347,15 +11910,15 @@ function resolveAecBinary() {
10347
11910
  if (existsSync4(bin) && statSync4(bin).mtimeMs >= statSync4(src).mtimeMs) return bin;
10348
11911
  if (spawnSync2("which", ["swiftc"]).status !== 0) return null;
10349
11912
  mkdirSync5(cacheDir, { recursive: true });
10350
- log20.info("compiling AEC mic helper (first run)\u2026");
11913
+ log25.info("compiling AEC mic helper (first run)\u2026");
10351
11914
  const build = spawnSync2("swiftc", ["-O", "-o", bin, src, "-Xlinker", "-sectcreate", "-Xlinker", "__TEXT", "-Xlinker", "__info_plist", "-Xlinker", plist], { encoding: "utf8" });
10352
11915
  if (build.status !== 0) {
10353
- log20.warn(`AEC build failed: ${build.stderr?.slice(0, 400)}`);
11916
+ log25.warn(`AEC build failed: ${build.stderr?.slice(0, 400)}`);
10354
11917
  return null;
10355
11918
  }
10356
11919
  const sign = spawnSync2("codesign", ["-fs", "-", bin], { encoding: "utf8" });
10357
11920
  if (sign.status !== 0) {
10358
- log20.warn(`codesign failed: ${sign.stderr?.slice(0, 200)}`);
11921
+ log25.warn(`codesign failed: ${sign.stderr?.slice(0, 200)}`);
10359
11922
  return null;
10360
11923
  }
10361
11924
  return bin;
@@ -10397,16 +11960,16 @@ var NodeMicSource = class {
10397
11960
  this.proc = spawn2(this.bin, [], { stdio: ["ignore", "pipe", "ignore"] });
10398
11961
  } else {
10399
11962
  if (spawnSync2("which", ["ffmpeg"]).status !== 0) throw new Error("voice I/O unavailable: no AEC helper and no ffmpeg on PATH");
10400
- log20.info("mic: raw capture (no AEC) \u2014 echo handled heuristically; headphones recommended");
11963
+ log25.info("mic: raw capture (no AEC) \u2014 echo handled heuristically; headphones recommended");
10401
11964
  this.proc = spawn2(
10402
11965
  "ffmpeg",
10403
11966
  ["-loglevel", "error", "-f", "avfoundation", "-i", detectFfmpegMic(), "-ar", String(STT_SAMPLE_RATE), "-ac", "1", "-f", "s16le", "-"],
10404
11967
  { stdio: ["ignore", "pipe", "pipe"] }
10405
11968
  );
10406
- this.proc.stderr.on("data", (d) => log20.warn(`ffmpeg: ${String(d).trim()}`));
11969
+ this.proc.stderr.on("data", (d) => log25.warn(`ffmpeg: ${String(d).trim()}`));
10407
11970
  }
10408
11971
  this.proc.on("exit", (c) => {
10409
- if (c && !this.stopped) log20.error(`mic capture exited (${c}) \u2014 check mic permission / MIC_DEVICE / MIC_AEC=0`);
11972
+ if (c && !this.stopped) log25.error(`mic capture exited (${c}) \u2014 check mic permission / MIC_DEVICE / MIC_AEC=0`);
10410
11973
  });
10411
11974
  this.proc.stdout.on("data", (chunk) => onChunk(chunk));
10412
11975
  }
@@ -10472,7 +12035,7 @@ var AecDuplexAudio = class {
10472
12035
  this.proc.stdin.on("error", () => {
10473
12036
  });
10474
12037
  this.proc.on("exit", (c) => {
10475
- if (c && !this.stopped) log20.error(`aec duplex audio exited (${c}) \u2014 check mic permission / MIC_AEC=0`);
12038
+ if (c && !this.stopped) log25.error(`aec duplex audio exited (${c}) \u2014 check mic permission / MIC_AEC=0`);
10476
12039
  });
10477
12040
  this.proc.stdout.on("data", (chunk) => {
10478
12041
  this.gotChunk = true;
@@ -10488,7 +12051,7 @@ var AecDuplexAudio = class {
10488
12051
  openMicSettings();
10489
12052
  this.onFatal?.("microphone permission denied \u2014 enable it in System Settings \u2192 Privacy & Security \u2192 Microphone for your terminal, then restart it");
10490
12053
  }
10491
- } else log20.debug(`mic-aec: ${s}`);
12054
+ } else log25.debug(`mic-aec: ${s}`);
10492
12055
  }
10493
12056
  });
10494
12057
  if (!this.noVpio && !this.triedFallback) {
@@ -10497,7 +12060,7 @@ var AecDuplexAudio = class {
10497
12060
  this.triedFallback = true;
10498
12061
  this.noVpio = true;
10499
12062
  this._aec = false;
10500
- log20.warn("mic-aec: VPIO delivered no audio in 2.5s \u2014 falling back to non-VPIO capture (no AEC \u2192 half-duplex, no barge-in)");
12063
+ log25.warn("mic-aec: VPIO delivered no audio in 2.5s \u2014 falling back to non-VPIO capture (no AEC \u2192 half-duplex, no barge-in)");
10501
12064
  this.onDegrade?.();
10502
12065
  this.killProc();
10503
12066
  this.spawnHelper();
@@ -10601,6 +12164,78 @@ var matchVoiceCommand = (text) => {
10601
12164
  const t = text.toLowerCase().replace(/[^a-z ]/g, " ").replace(/\s+/g, " ").trim().replace(/^(?:hey|ok|okay|please) /, "").replace(/ (?:please|now)$/, "");
10602
12165
  return /^(voice off|mute|stop listening)( \1)*$/.test(t) ? "off" : null;
10603
12166
  };
12167
+ var replacedVoiceTurn = (last, meta) => meta?.supersedes !== void 0 && last?.text === meta.supersedes ? last : void 0;
12168
+ var CutInAbsorber = class {
12169
+ cut = "";
12170
+ absorbed;
12171
+ interjected(heard) {
12172
+ this.cut = heard;
12173
+ this.absorbed = void 0;
12174
+ }
12175
+ absorbs(text, meta) {
12176
+ const cut = this.cut || (meta?.supersedes !== void 0 && meta.supersedes === this.absorbed?.text ? this.absorbed.cut : "");
12177
+ this.cut = "";
12178
+ this.absorbed = void 0;
12179
+ const words2 = (s) => s.trim().split(/\s+/).filter(Boolean).length;
12180
+ if (!cut || words2(text) - words2(cut) >= 3) return false;
12181
+ this.absorbed = { text, cut };
12182
+ return true;
12183
+ }
12184
+ };
12185
+ function splitCut(cut) {
12186
+ if (!cut) return { reply: null, delivery: "", heard: "" };
12187
+ const queued = cut.queued ?? [];
12188
+ if (!cut.queuedTurn) return { reply: cut.full ? cut : null, delivery: queued.join(" ").trim(), heard: "" };
12189
+ let at = cut.full.length;
12190
+ if (cut.full.length - cut.heard.length > 12) {
12191
+ const sentence = Math.max(...[". ", "! ", "? "].map((b) => cut.heard.lastIndexOf(b)));
12192
+ at = cut.exact ? cut.heard.length : sentence < 0 ? 0 : sentence + 2;
12193
+ }
12194
+ const heard = cut.full.slice(0, at).trim();
12195
+ return { reply: null, delivery: [cut.full.slice(at), ...queued].map((x) => x.trim()).filter(Boolean).join(" "), heard: heard && heard + " " };
12196
+ }
12197
+ function takeCut(v, forVoiceLine) {
12198
+ const cut = v?.takeInterruptedReply() ?? null;
12199
+ return splitCut(forVoiceLine ? cut : null);
12200
+ }
12201
+ function bindOverlapInstinct(v, instinct, live, decideMs = 900) {
12202
+ const o = v.options;
12203
+ const { onDiag } = o;
12204
+ o.overlapDecideMs = decideMs;
12205
+ instinct.options.overlapSettleMs = 0;
12206
+ o.onDiag = (ev) => {
12207
+ onDiag(ev);
12208
+ if (!live()) return;
12209
+ if (ev.kind === "overlap_pause") void instinct.overlap(String(ev.text ?? ""));
12210
+ else if (/^(?:barge_in|overlap_resume|overlap_end|interrupt|tts_turn_begin)$/.test(ev.kind)) instinct.overlapEnded();
12211
+ };
12212
+ Object.assign(instinct.options, {
12213
+ onYield: () => {
12214
+ if (!live()) return false;
12215
+ if (v.decideOverlap("yield")) return;
12216
+ if (!v.audible) return false;
12217
+ v.interrupt();
12218
+ o.onBargeIn("speaking");
12219
+ },
12220
+ // `heard` = what Jev judged: the engine absorbs only those words — a reaction that grows past them is re-decided.
12221
+ onKeepTalking: (heard) => live() ? v.decideOverlap("keep_talking", heard) : false
12222
+ });
12223
+ }
12224
+ function bindAckInstinct(v, instinct) {
12225
+ const o = v.options;
12226
+ const { onDiag } = o;
12227
+ o.pickAck = (c) => instinct.pickAck(c);
12228
+ instinct.options.ackSettleMs = 2 * o.adaptiveAckMs + 200;
12229
+ o.onDiag = (ev) => {
12230
+ try {
12231
+ if (ev.kind === "ack_fired") instinct.ackOutcome(String(ev.phrase));
12232
+ else if (ev.kind === "ack_skipped" || ev.kind === "ack_cancelled") instinct.ackOutcome(null);
12233
+ } catch (e) {
12234
+ log25.warn(`ack outcome tap failed: ${e instanceof Error ? e.message : e}`);
12235
+ }
12236
+ onDiag(ev);
12237
+ };
12238
+ }
10604
12239
  var VoiceIOOptions = class extends VoiceEngineOptions {
10605
12240
  sonioxApiKey = process.env.SONIOX_API_KEY ?? "";
10606
12241
  cartesiaApiKey = process.env.CARTESIA_API_KEY ?? "";
@@ -10717,7 +12352,7 @@ function fakeVoiceParts(uttFile) {
10717
12352
  init_logging();
10718
12353
  import { appendFile, mkdirSync as mkdirSync6 } from "fs";
10719
12354
  import { dirname as dirname4 } from "path";
10720
- var log21 = forComponent("VoiceDiag");
12355
+ var log26 = forComponent("VoiceDiag");
10721
12356
  var JsonlDiagSink = class {
10722
12357
  constructor(path) {
10723
12358
  this.path = path;
@@ -10758,7 +12393,7 @@ var JsonlDiagSink = class {
10758
12393
  disable(e) {
10759
12394
  this.disabled = true;
10760
12395
  this.queue.length = 0;
10761
- log21.debug(`voice diag sink disabled (${this.path}): ${e instanceof Error ? e.message : e}`);
12396
+ log26.debug(`voice diag sink disabled (${this.path}): ${e instanceof Error ? e.message : e}`);
10762
12397
  }
10763
12398
  /** True once a failure permanently disabled the sink (tests/inspection). */
10764
12399
  get isDisabled() {
@@ -10828,7 +12463,7 @@ async function loadConfig(cwd) {
10828
12463
 
10829
12464
  // cli/hooks-config.ts
10830
12465
  import { spawnSync as spawnSync3 } from "child_process";
10831
- var log22 = forComponent("hooks");
12466
+ var log27 = forComponent("hooks");
10832
12467
  var escapeRegex = (s) => s.replace(/[.+^${}()|[\]\\]/g, "\\$&");
10833
12468
  function ruleMatches(rule, toolName) {
10834
12469
  if (!rule.tool || rule.tool === "*") return true;
@@ -10845,7 +12480,7 @@ function runCmd(rule, env) {
10845
12480
  });
10846
12481
  return { code: r.status ?? 1, out: ((r.stdout ?? "") + (r.stderr ?? "")).trim() };
10847
12482
  } catch (e) {
10848
- log22.debug(`hook command failed: ${rule.command}`, e);
12483
+ log27.debug(`hook command failed: ${rule.command}`, e);
10849
12484
  return { code: 1, out: String(e?.message ?? e) };
10850
12485
  }
10851
12486
  }
@@ -10949,10 +12584,10 @@ function formatDiff(ops, opts = {}) {
10949
12584
  }
10950
12585
 
10951
12586
  // cli/session.ts
10952
- import { existsSync as existsSync6, mkdirSync as mkdirSync7, readFileSync as readFileSync7, writeFileSync as writeFileSync4, readdirSync, renameSync, symlinkSync as symlinkSync2, unlinkSync, readlinkSync } from "fs";
12587
+ import { existsSync as existsSync6, mkdirSync as mkdirSync7, readFileSync as readFileSync7, writeFileSync as writeFileSync4, readdirSync, renameSync, symlinkSync as symlinkSync2, unlinkSync, readlinkSync, statSync as statSync5 } from "fs";
10953
12588
  import { homedir as homedir5 } from "os";
10954
12589
  import { join as join8 } from "path";
10955
- var log23 = forComponent("session");
12590
+ var log28 = forComponent("session");
10956
12591
  var globalDir = () => join8(homedir5(), ".agent", "sessions");
10957
12592
  var SessionStore = class {
10958
12593
  dir;
@@ -10997,7 +12632,7 @@ var SessionStore = class {
10997
12632
  }
10998
12633
  load(id) {
10999
12634
  if (!this.safeId(id)) {
11000
- log23.debug(`rejecting unsafe session id: ${id}`);
12635
+ log28.debug(`rejecting unsafe session id: ${id}`);
11001
12636
  return void 0;
11002
12637
  }
11003
12638
  const path = join8(this.dir, `${id}.json`);
@@ -11005,7 +12640,7 @@ var SessionStore = class {
11005
12640
  try {
11006
12641
  return JSON.parse(readFileSync7(path, "utf8"));
11007
12642
  } catch (e) {
11008
- log23.debug(`unreadable session ${id} \u2014 ignoring`, e);
12643
+ log28.debug(`unreadable session ${id} \u2014 ignoring`, e);
11009
12644
  return void 0;
11010
12645
  }
11011
12646
  }
@@ -11018,11 +12653,26 @@ var SessionStore = class {
11018
12653
  try {
11019
12654
  metas.push(JSON.parse(readFileSync7(join8(this.dir, f), "utf8")).meta);
11020
12655
  } catch (e) {
11021
- log23.debug(`skipping unreadable session file ${f}`, e);
12656
+ log28.debug(`skipping unreadable session file ${f}`, e);
11022
12657
  }
11023
12658
  }
11024
12659
  return metas.sort((a, b) => b.updated - a.updated);
11025
12660
  }
12661
+ /** Cheap listing — ids from filenames + mtime/size from stat, NO parsing (list() parses every file: 870
12662
+ * sessions / 157MB took ~150ms+ synchronously). Most-recently-written first. */
12663
+ files() {
12664
+ if (!existsSync6(this.dir)) return [];
12665
+ const out = [];
12666
+ for (const f of readdirSync(this.dir)) {
12667
+ if (!f.endsWith(".json")) continue;
12668
+ try {
12669
+ const st = statSync5(join8(this.dir, f));
12670
+ out.push({ id: f.slice(0, -5), mtime: st.mtimeMs, size: st.size });
12671
+ } catch {
12672
+ }
12673
+ }
12674
+ return out.sort((a, b) => b.mtime - a.mtime);
12675
+ }
11026
12676
  latest() {
11027
12677
  return this.list()[0];
11028
12678
  }
@@ -11083,6 +12733,61 @@ function titleOf(messages) {
11083
12733
  const t = contentText(u?.content).replace(/\s+/g, " ").trim();
11084
12734
  return t.length > 60 ? t.slice(0, 57) + "\u2026" : t || "(empty)";
11085
12735
  }
12736
+ function recapLine(m) {
12737
+ if (m.role !== "user" && m.role !== "assistant") return "";
12738
+ let t = contentText(m.content);
12739
+ if (m.role === "user" && /^\[(?:session started|reminder)\]/.test(t)) return "";
12740
+ const spoken = [...t.matchAll(/<spoken>([\s\S]*?)<\/spoken>/g)].map((x) => x[1]).join(" ");
12741
+ if (spoken) t = spoken;
12742
+ t = t.replace(/\s*\[the user interrupted you[\s\S]*$/, "").replace(/\[[a-z_ ]+\]\s*/gi, "").replace(/\s+/g, " ").trim();
12743
+ return t ? `${m.role}: ${t}` : "";
12744
+ }
12745
+ function sessionRecap(store, currentId, which, cap = 2500, maxParseBytes = 4e6) {
12746
+ const others = store.files().filter((f) => f.id !== currentId);
12747
+ if (!others.length) return "No earlier sessions in this project.";
12748
+ const created = (id) => {
12749
+ const m = id.match(/^(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})(\d{2})/);
12750
+ return m ? new Date(+m[1], +m[2] - 1, +m[3], +m[4], +m[5], +m[6]).getTime() : NaN;
12751
+ };
12752
+ const when = (ts) => {
12753
+ if (!Number.isFinite(ts)) return "unknown time";
12754
+ const d = new Date(ts), today = (/* @__PURE__ */ new Date()).toDateString() === d.toDateString();
12755
+ return `${today ? "today" : d.toLocaleDateString("en-US", { month: "short", day: "numeric" })} ${d.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" })}`;
12756
+ };
12757
+ const load = (f) => f.size <= maxParseBytes ? store.load(f.id) : void 0;
12758
+ const w = which?.trim();
12759
+ if (!w) {
12760
+ const rows = others.slice(0, 5).map((f) => {
12761
+ const data2 = load(f);
12762
+ const asks = (data2?.messages ?? []).filter((m) => m.role === "user").map(recapLine).filter(Boolean).slice(0, 3).map((l) => `"${l.slice(6, 86)}"`);
12763
+ return `- ${f.id} (${when(created(f.id))}${data2 ? `, ${data2.meta.turns} turns` : ""})${asks.length ? `: ${asks.join("; ")}` : ""}`;
12764
+ });
12765
+ return `Earlier sessions (newest first) \u2014 to read one, call QuickLook with what:"sessions" and path:"<id>":
12766
+ ${rows.join("\n")}`;
12767
+ }
12768
+ const recent = others.slice(0, 5).map((f) => f.id).join(", ");
12769
+ let hit;
12770
+ if (/^(latest|last|previous|prev)$/i.test(w)) {
12771
+ const cur = created(currentId);
12772
+ hit = others.filter((f) => created(f.id) < cur).sort((a, b) => created(b.id) - created(a.id))[0] ?? others[0];
12773
+ } else {
12774
+ hit = others.find((f) => f.id === w);
12775
+ if (!hit) {
12776
+ const pre = others.filter((f) => f.id.startsWith(w));
12777
+ if (pre.length > 1) return `'${w}' matches ${pre.length} sessions \u2014 use the full id: ${pre.slice(0, 5).map((f) => f.id).join(", ")}`;
12778
+ hit = pre[0];
12779
+ }
12780
+ }
12781
+ if (!hit) return `No session '${w}'. Recent: ${recent}`;
12782
+ const data = load(hit);
12783
+ if (!data) return `Session ${hit.id} is too large (or unreadable) to recap here \u2014 ask a worker (Act) to read it.`;
12784
+ let body = data.messages.map(recapLine).filter(Boolean).map((l) => l.length > 600 ? `${l.slice(0, 600)}\u2026` : l).join("\n");
12785
+ if (body.length > cap) body = `\u2026(earlier lines omitted)
12786
+ ${body.slice(-cap)}`;
12787
+ return `Session ${data.meta.id} (${when(data.meta.created)}):
12788
+ ${body || "(no conversation)"}
12789
+ (That is everything recorded. Where it does not say why something was done or what was meant, say you do not know \u2014 never supply a reason.)`;
12790
+ }
11086
12791
 
11087
12792
  // cli/checkpoints.ts
11088
12793
  var MUTATORS = /* @__PURE__ */ new Set(["Write", "Edit", "MultiEdit", "ApplyEdits"]);
@@ -11187,7 +12892,7 @@ import { promisify } from "util";
11187
12892
  import { writeFileSync as writeFileSync5, mkdirSync as mkdirSync8 } from "fs";
11188
12893
  import { homedir as homedir6 } from "os";
11189
12894
  import { join as join9, resolve as resolve3, sep as sep3, parse as parsePath } from "path";
11190
- var log24 = forComponent("checkpoints");
12895
+ var log29 = forComponent("checkpoints");
11191
12896
  var exec = promisify(execFile3);
11192
12897
  var DEFAULT_EXCLUDE = [".agent/", ".git/", "node_modules/", "dist/", "build/", ".next/", "target/", ".venv/", "__pycache__/", "*.log"];
11193
12898
  function isBroadRoot(p) {
@@ -11233,7 +12938,7 @@ var ShadowRepo = class {
11233
12938
  writeFileSync5(join9(this.gitDir, "info", "exclude"), this.exclude.join("\n") + "\n");
11234
12939
  this.ready = true;
11235
12940
  } catch (e) {
11236
- log24.debug(`git checkpoints unavailable for ${this.workTree}`, e);
12941
+ log29.debug(`git checkpoints unavailable for ${this.workTree}`, e);
11237
12942
  this.ready = false;
11238
12943
  }
11239
12944
  return this.ready;
@@ -11244,7 +12949,7 @@ var ShadowRepo = class {
11244
12949
  }
11245
12950
  async commit(label, forced = []) {
11246
12951
  await this.run("add", "-A");
11247
- for (const p of forced) await this.run("add", "-f", "--", p).catch((e) => log24.debug(`force-add failed: ${p}`, e));
12952
+ for (const p of forced) await this.run("add", "-f", "--", p).catch((e) => log29.debug(`force-add failed: ${p}`, e));
11248
12953
  await this.run("commit", "--allow-empty", "-q", "-m", label);
11249
12954
  }
11250
12955
  /** Inject the CURRENT (pre-edit) content of `paths` into the turn-open restore point by amending it.
@@ -11252,8 +12957,8 @@ var ShadowRepo = class {
11252
12957
  * turn-boundary `add -A` would never have captured it. Amend (vs a new commit) keeps one restore
11253
12958
  * point per turn, so the REPL's turn↔frame mapping stays intact. */
11254
12959
  async amendForced(paths) {
11255
- for (const p of paths) await this.run("add", "-f", "--", p).catch((e) => log24.debug(`force-capture failed: ${p}`, e));
11256
- await this.run("commit", "--amend", "--no-edit", "-q", "--allow-empty").catch((e) => log24.debug("amend failed", e));
12960
+ for (const p of paths) await this.run("add", "-f", "--", p).catch((e) => log29.debug(`force-capture failed: ${p}`, e));
12961
+ await this.run("commit", "--amend", "--no-edit", "-q", "--allow-empty").catch((e) => log29.debug("amend failed", e));
11257
12962
  }
11258
12963
  /** Commits on `ref`, oldest-first (canonical index space). */
11259
12964
  async log(ref) {
@@ -11313,7 +13018,7 @@ var ShadowRepo = class {
11313
13018
  await this.run("gc", "--auto", "-q").catch(() => {
11314
13019
  });
11315
13020
  } catch (e) {
11316
- log24.debug("checkpoint prune failed", e);
13021
+ log29.debug("checkpoint prune failed", e);
11317
13022
  }
11318
13023
  }
11319
13024
  };
@@ -11381,7 +13086,7 @@ var GitCheckpoints = class {
11381
13086
  use(sessionId) {
11382
13087
  if (sessionId === this.session) return;
11383
13088
  this.session = sessionId;
11384
- if (this.started) for (const r of this.repos) void r.point(this.ref()).catch((e) => log24.debug("re-point failed", e));
13089
+ if (this.started) for (const r of this.repos) void r.point(this.ref()).catch((e) => log29.debug("re-point failed", e));
11385
13090
  }
11386
13091
  async begin(label) {
11387
13092
  if (!await this.start()) return;
@@ -11401,7 +13106,7 @@ var GitCheckpoints = class {
11401
13106
  process.stderr.write(`\x1B[2m checkpoints: '${this.repos[i].root}' too large to snapshot in ${Math.round(this.options.opTimeoutMs / 1e3)}s \u2014 file rewind disabled here\x1B[0m
11402
13107
  `);
11403
13108
  } else {
11404
- log24.debug("checkpoint commit failed", e);
13109
+ log29.debug("checkpoint commit failed", e);
11405
13110
  survivors.push(this.repos[i]);
11406
13111
  survivorCaches.push(this.caches[i] ?? []);
11407
13112
  }
@@ -11436,7 +13141,7 @@ var GitCheckpoints = class {
11436
13141
  if (this.forced.has(abs)) continue;
11437
13142
  this.forced.add(abs);
11438
13143
  const i = this.repoIndexFor(abs);
11439
- if (i >= 0) await this.repos[i].amendForced([abs]).catch((e) => log24.debug("amendForced failed", e));
13144
+ if (i >= 0) await this.repos[i].amendForced([abs]).catch((e) => log29.debug("amendForced failed", e));
11440
13145
  }
11441
13146
  }
11442
13147
  };
@@ -12515,8 +14220,8 @@ var REWIND = "\0REWIND";
12515
14220
  var PASTE_PREVIEW_MAX = 40;
12516
14221
  function pastePreview(text) {
12517
14222
  const line = text.split("\n").find((l) => l.trim()) ?? "";
12518
- const clean2 = line.replace(/[[\]]/g, "").replace(/\s+/g, " ").trim();
12519
- return clean2.length > PASTE_PREVIEW_MAX ? clean2.slice(0, PASTE_PREVIEW_MAX) + "\u2026" : clean2;
14223
+ const clean3 = line.replace(/[[\]]/g, "").replace(/\s+/g, " ").trim();
14224
+ return clean3.length > PASTE_PREVIEW_MAX ? clean3.slice(0, PASTE_PREVIEW_MAX) + "\u2026" : clean3;
12520
14225
  }
12521
14226
  function applyVimNormal(s, key, str) {
12522
14227
  const k = key?.name;
@@ -13289,7 +14994,7 @@ import { spawnSync as spawnSync5 } from "child_process";
13289
14994
  import { writeFileSync as writeFileSync8, mkdirSync as mkdirSync10, readdirSync as readdirSync2, unlinkSync as unlinkSync3, chmodSync, existsSync as existsSync7 } from "fs";
13290
14995
  import { homedir as homedir8 } from "os";
13291
14996
  import { join as join12 } from "path";
13292
- var log25 = forComponent("os-sched");
14997
+ var log30 = forComponent("os-sched");
13293
14998
  var OsScheduler = class {
13294
14999
  options;
13295
15000
  constructor(options) {
@@ -13351,7 +15056,7 @@ var OsScheduler = class {
13351
15056
  }
13352
15057
  }
13353
15058
  } catch (e) {
13354
- log25.debug(`cancel ${id}`, e);
15059
+ log30.debug(`cancel ${id}`, e);
13355
15060
  }
13356
15061
  for (const f of [`${id}.json`, `${id}.sh`]) {
13357
15062
  try {
@@ -13468,7 +15173,7 @@ import { spawn as spawn3 } from "child_process";
13468
15173
  import { existsSync as existsSync8, mkdirSync as mkdirSync11, unlinkSync as unlinkSync4, readdirSync as readdirSync3 } from "fs";
13469
15174
  import { homedir as homedir9 } from "os";
13470
15175
  import { join as join13 } from "path";
13471
- var log26 = forComponent("remote-trigger");
15176
+ var log31 = forComponent("remote-trigger");
13472
15177
  var TRIGGER_DIR = () => join13(homedir9(), ".agent", "triggers");
13473
15178
  var sockPath = (sessionId, dir = TRIGGER_DIR()) => join13(dir, `${sessionId}.sock`);
13474
15179
  var TriggerServer = class {
@@ -13506,13 +15211,13 @@ var TriggerServer = class {
13506
15211
  conn.end(JSON.stringify({ ok: false, error: String(e) }) + "\n");
13507
15212
  }
13508
15213
  });
13509
- conn.on("error", (e) => log26.debug("trigger conn error", e));
15214
+ conn.on("error", (e) => log31.debug("trigger conn error", e));
13510
15215
  });
13511
- this.server.on("error", (e) => log26.debug("trigger server error", e));
15216
+ this.server.on("error", (e) => log31.debug("trigger server error", e));
13512
15217
  this.server.listen(p);
13513
15218
  this.path = p;
13514
15219
  } catch (e) {
13515
- log26.debug("trigger server unavailable", e);
15220
+ log31.debug("trigger server unavailable", e);
13516
15221
  }
13517
15222
  }
13518
15223
  /** Re-bind on /resume (the session id changed). */
@@ -13634,7 +15339,7 @@ var strike = C("9");
13634
15339
  var link = (text, url) => useColor ? `\x1B]8;;${url}\x1B\\${cyan(text)}\x1B]8;;\x1B\\` : `${text} (${url})`;
13635
15340
  var mdPalette = { bold, dim, cyan, italic, strike, green, link };
13636
15341
  var err = (s) => process.stderr.write(s);
13637
- var log27 = forComponent("cli");
15342
+ var log32 = forComponent("cli");
13638
15343
  var VERSION = (() => {
13639
15344
  try {
13640
15345
  return JSON.parse(readFileSync9(new URL("../package.json", import.meta.url), "utf8")).version ?? "?";
@@ -14390,7 +16095,7 @@ function costOf(pricing, promptTokens = 0, completionTokens = 0, cacheCreationTo
14390
16095
  function turnCost(model, usage) {
14391
16096
  return costOf(getModelInfo(model)?.pricing, usage?.promptTokens ?? 0, usage?.completionTokens ?? 0, usage?.cacheCreationTokens ?? 0, usage?.cacheReadTokens ?? 0, model);
14392
16097
  }
14393
- async function evaluateGoal(ai, condition, transcript, log28) {
16098
+ async function evaluateGoal(ai, condition, transcript, log33) {
14394
16099
  const recent = transcript.filter((m) => m.role === "assistant").slice(-8).map((m) => {
14395
16100
  const text = typeof m.content === "string" ? m.content : m.content.filter((p) => p.type === "text").map((p) => p.text).join(" ");
14396
16101
  return text.slice(0, 600);
@@ -14410,7 +16115,7 @@ ${recent}` }
14410
16115
  const match = r.content.match(/\{[\s\S]*\}/);
14411
16116
  if (match) return JSON.parse(match[0]);
14412
16117
  } catch (e) {
14413
- log28(dim(` (goal evaluator error: ${e?.message ?? e})
16118
+ log33(dim(` (goal evaluator error: ${e?.message ?? e})
14414
16119
  `));
14415
16120
  }
14416
16121
  return { met: false, reason: "evaluation unclear" };
@@ -14631,7 +16336,7 @@ function mcpAgentTools(mounted, opts) {
14631
16336
  return tools;
14632
16337
  }
14633
16338
  async function closeMcp(mounted) {
14634
- await Promise.all(mounted.map((m) => m.client.close().catch((e) => log27.debug("mcp close failed", e))));
16339
+ await Promise.all(mounted.map((m) => m.client.close().catch((e) => log32.debug("mcp close failed", e))));
14635
16340
  }
14636
16341
  var IMG_EXT = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp" };
14637
16342
  function mentionRefs(line) {
@@ -14646,7 +16351,7 @@ function readImageParts(cwd, line) {
14646
16351
  if (!mime) continue;
14647
16352
  const abs = ref.startsWith("~/") ? untilde(ref) : resolve4(cwd, ref);
14648
16353
  try {
14649
- statSync5(abs);
16354
+ statSync6(abs);
14650
16355
  } catch {
14651
16356
  continue;
14652
16357
  }
@@ -14662,7 +16367,7 @@ function pastePathClassifier(cwd) {
14662
16367
  if (!/^(\/|~\/|\.\/|\.\.\/)/.test(t)) return null;
14663
16368
  const abs = t.startsWith("~/") ? untilde(t) : resolve4(cwd, t);
14664
16369
  try {
14665
- if (!statSync5(abs).isFile()) return null;
16370
+ if (!statSync6(abs).isFile()) return null;
14666
16371
  } catch {
14667
16372
  return null;
14668
16373
  }
@@ -14683,7 +16388,7 @@ async function expandMentions(fs, line) {
14683
16388
  if (loaded.includes(ref) || missing.includes(ref)) continue;
14684
16389
  if (ref.includes(":") && mcpMentionResolver) {
14685
16390
  const body = await mcpMentionResolver(ref).catch((e) => {
14686
- log27.debug("mcp mention resolve failed", e);
16391
+ log32.debug("mcp mention resolve failed", e);
14687
16392
  return null;
14688
16393
  });
14689
16394
  if (body != null) {
@@ -14770,7 +16475,7 @@ async function runTurn(agent, store, session, task, cp, cwd = process.cwd(), sen
14770
16475
  try {
14771
16476
  store.save(session);
14772
16477
  } catch (ex) {
14773
- log27.debug("mid-turn session flush failed", ex);
16478
+ log32.debug("mid-turn session flush failed", ex);
14774
16479
  }
14775
16480
  }
14776
16481
  return origNotify(e);
@@ -14791,8 +16496,8 @@ async function runTurn(agent, store, session, task, cp, cwd = process.cwd(), sen
14791
16496
  } finally {
14792
16497
  spinner.stop();
14793
16498
  spinner.turnStart = 0;
14794
- activeTurn = null;
14795
- agent.options.signal = void 0;
16499
+ if (activeTurn === ctrl) activeTurn = null;
16500
+ if (agent.options.signal === ctrl.signal) agent.options.signal = void 0;
14796
16501
  if (host && origNotify) host.notify = origNotify;
14797
16502
  }
14798
16503
  agent.options.host?.flushText?.();
@@ -14919,14 +16624,14 @@ var isCancelTeardown = (e) => {
14919
16624
  function installCancelGuards(mounted) {
14920
16625
  process.on("unhandledRejection", (e) => {
14921
16626
  if (isCancelTeardown(e)) {
14922
- log27.debug("suppressed unhandledRejection (cursor stream cancel)", e);
16627
+ log32.debug("suppressed unhandledRejection (cursor stream cancel)", e);
14923
16628
  return;
14924
16629
  }
14925
- log27.error("unhandledRejection", e);
16630
+ log32.error("unhandledRejection", e);
14926
16631
  });
14927
16632
  process.on("uncaughtException", (e) => {
14928
16633
  if (isCancelTeardown(e)) {
14929
- log27.debug("suppressed uncaughtException (cursor stream cancel)", e);
16634
+ log32.debug("suppressed uncaughtException (cursor stream cancel)", e);
14930
16635
  return;
14931
16636
  }
14932
16637
  console.error(e);
@@ -14986,6 +16691,18 @@ async function repl(args, ai, cfg, cwd) {
14986
16691
  let dx;
14987
16692
  let voiceIO;
14988
16693
  let voiceDiag;
16694
+ const decider = duplex && process.env.TYPESAFE_AI_KEY ? new DecideClient({ apiKey: process.env.TYPESAFE_AI_KEY }) : void 0;
16695
+ const instinct = decider && process.env.VOICE_INSTINCT === "1" ? new Instinct({
16696
+ decider,
16697
+ context: () => ({ agentSaid: dx?.recentExcerpt(1).replace(/^\w+: /, ""), recent: dx?.recentExcerpt(4) }),
16698
+ onDecision: ({ kind, ...d }) => {
16699
+ voiceDiag?.push({ t: performance.now(), kind: `instinct_${kind}`, ...d });
16700
+ const probs = Object.entries(d.probabilities).sort((a, b) => b[1] - a[1]).map(([k, p]) => `${k} ${Math.round(p * 100)}%`).join(" \xB7 ");
16701
+ const act = d.acted && !/stale|skipped/.test(d.acted) ? yellow(`\u26A1 ${d.acted}`) : dim(d.acted ?? "no-op");
16702
+ err("\r\x1B[0J" + dim(` \u{1F9E0} jev ${kind.padEnd(12)} ${probs} ${d.ms}ms "${d.heard.slice(-40)}" `) + act + "\n");
16703
+ editorRef?.redrawNow();
16704
+ }
16705
+ }) : void 0;
14989
16706
  let voiceLineOpen = false;
14990
16707
  const emotionsOn = process.env.VOICE_EMOTIONS !== "0";
14991
16708
  let showEmotions = process.env.VOICE_SHOW_EMOTIONS === "1";
@@ -15176,6 +16893,8 @@ async function repl(args, ai, cfg, cwd) {
15176
16893
  };
15177
16894
  dx = new DuplexAgent({
15178
16895
  ai,
16896
+ ...instinct ? { bargeIntent: (t) => instinct.bargeIntent(t) } : {},
16897
+ decider,
15179
16898
  fs: agent.options.fs,
15180
16899
  memoryDir: agent.options.memoryDir,
15181
16900
  memoryUserDir: agent.options.memoryUserDir,
@@ -15185,7 +16904,7 @@ async function repl(args, ai, cfg, cwd) {
15185
16904
  providerOptionsFor: (m) => providerOptionsFor(m, cwd, cfg.mcpServers, mounted.map((x) => x.name)),
15186
16905
  ...(args.thinkModel ?? cfg.thinkModel) !== void 0 ? { thinkModel: (args.thinkModel ?? cfg.thinkModel) === false ? false : resolveModelOrNewest(String(args.thinkModel ?? cfg.thinkModel)) } : {},
15187
16906
  host,
15188
- ...args.voice ? { voiceStyle: "conversational", progressUpdates: true, askRelay: true, emotionTags: emotionsOn } : {},
16907
+ ...args.voice ? { voiceStyle: "conversational", progressUpdates: true, askRelay: true, emotionTags: emotionsOn, spokenBudgetChars: 350 } : {},
15189
16908
  // voice: progress asides + worker questions relayed through the conversation; emotion tags taught only with a TTS that speaks them
15190
16909
  // Per-TASK checkpoint frames (the natural undo unit in duplex = one delegation): opened BEFORE
15191
16910
  // the worker spawns (post-spawn would race its first edits). `checkpoints` is bound below.
@@ -15215,7 +16934,9 @@ async function repl(args, ai, cfg, cwd) {
15215
16934
  }
15216
16935
  }
15217
16936
  return parts.length ? parts.join("\n").slice(0, 2e3) : "(no memory yet)";
15218
- }
16937
+ },
16938
+ // Cross-session recall: earlier conversations in this project (path = a session id / "previous").
16939
+ sessions: (which) => sessionRecap(store, session.meta.id, which)
15219
16940
  },
15220
16941
  // The voice runs on the REAL fs (it has no fs tools — harmless) so @mentions, !cmd and #note
15221
16942
  // resolve against the project; + CC-parity chrome for its own tool calls (⚙ Act …).
@@ -15226,7 +16947,7 @@ async function repl(args, ai, cfg, cwd) {
15226
16947
  }
15227
16948
  const face = dx ? dx.voice : agent;
15228
16949
  const work = workerOptions ?? agent.options;
15229
- const sendVia = dx ? (c) => dx.send(c) : void 0;
16950
+ const sendVia = dx ? (c, o) => dx.send(c, o) : void 0;
15230
16951
  const baseRules = () => [
15231
16952
  ...parsePermRules({ deny: args.disallowedTools }),
15232
16953
  ...parsePermRules({ allow: args.allowedTools }),
@@ -15331,7 +17052,7 @@ async function repl(args, ai, cfg, cwd) {
15331
17052
  try {
15332
17053
  mkdirSync12(dir, { recursive: true });
15333
17054
  } catch (e) {
15334
- log27.debug("paste image dir create failed", e);
17055
+ log32.debug("paste image dir create failed", e);
15335
17056
  }
15336
17057
  const img = grabClipboardImage(dir, String(Date.now()));
15337
17058
  return img ? { display: "Image", ref: "@" + img.path, path: img.path } : null;
@@ -15444,7 +17165,7 @@ Added entries are loadable now via the Skill/SlashCommand tools; removed ones ar
15444
17165
  mkdirSync12(join14(cwd, ".agent"), { recursive: true });
15445
17166
  appendFileSync(histPath, line + "\n");
15446
17167
  } catch (e) {
15447
- log27.debug("history write failed", e);
17168
+ log32.debug("history write failed", e);
15448
17169
  }
15449
17170
  };
15450
17171
  const ago = (t) => {
@@ -15516,7 +17237,7 @@ Added entries are loadable now via the Skill/SlashCommand tools; removed ones ar
15516
17237
  try {
15517
17238
  store.save(session);
15518
17239
  } catch (e) {
15519
- log27.debug("session save after rewind failed", e);
17240
+ log32.debug("session save after rewind failed", e);
15520
17241
  }
15521
17242
  err(green(" \u27F2 jumped back") + dim(` \u2014 ${face.transcript.length} message(s) kept; edit + resend
15522
17243
  `));
@@ -15542,7 +17263,8 @@ Added entries are loadable now via the Skill/SlashCommand tools; removed ones ar
15542
17263
  else err(red(" no such session\n"));
15543
17264
  };
15544
17265
  const announcedTasks = /* @__PURE__ */ new Set();
15545
- const turn = async (task) => {
17266
+ const replacedVoiceTurns = /* @__PURE__ */ new Set();
17267
+ const turn = async (task, opts) => {
15546
17268
  if (bangContext.length) {
15547
17269
  task = `${bangContext.join("\n\n")}
15548
17270
 
@@ -15550,20 +17272,20 @@ ${task}`;
15550
17272
  bangContext.length = 0;
15551
17273
  }
15552
17274
  const delta = await refreshCatalogs().catch((e) => {
15553
- log27.debug("catalog refresh failed", e);
17275
+ log32.debug("catalog refresh failed", e);
15554
17276
  return "";
15555
17277
  });
15556
17278
  if (delta) {
15557
17279
  err(dim(" \u27F3 skill/command catalog changed \u2014 delta attached to this turn\n"));
15558
17280
  task += "\n\n" + delta;
15559
17281
  }
15560
- const r = await runTurn(face, store, session, task, duplex ? void 0 : checkpoints, cwd, sendVia);
17282
+ const r = await runTurn(face, store, session, task, duplex ? void 0 : checkpoints, cwd, sendVia && ((c) => sendVia(c, opts)));
15561
17283
  if (voiceIO) {
15562
17284
  voiceReasonEnd();
15563
17285
  voiceEchoEnd();
15564
17286
  editorRef?.resume();
15565
17287
  }
15566
- voiceIO?.endSpeech();
17288
+ if (!(opts?.id && replacedVoiceTurns.delete(opts.id))) voiceIO?.endSpeech();
15567
17289
  if (dx) {
15568
17290
  const fresh = [...dx.tasks.values()].filter((t) => t.status === "running" && !announcedTasks.has(t.id));
15569
17291
  fresh.forEach((t) => announcedTasks.add(t.id));
@@ -15814,7 +17536,7 @@ ${task}`;
15814
17536
  desc: "rescan skills/commands dirs and rebuild the system prompt (one cache miss) \u2014 picks up entries created mid-session",
15815
17537
  run: async () => {
15816
17538
  await refreshCatalogs().catch((e) => {
15817
- log27.debug("catalog refresh failed", e);
17539
+ log32.debug("catalog refresh failed", e);
15818
17540
  });
15819
17541
  face.reprepare();
15820
17542
  err(green(` \u2713 reloaded \u2014 ${skills.length} skill(s), ${cmds.length} command(s); system prompt rebuilds on next message
@@ -16316,7 +18038,7 @@ ${task}`;
16316
18038
  try {
16317
18039
  for (const def of (await loadAgents(fs2, d)).agents) if (!seen.has(def.name)) seen.set(def.name, { def, from: d });
16318
18040
  } catch (e) {
16319
- log27.debug(`loadAgents(${d}) failed`, e);
18041
+ log32.debug(`loadAgents(${d}) failed`, e);
16320
18042
  }
16321
18043
  }
16322
18044
  if (!seen.size) {
@@ -16404,7 +18126,7 @@ ${task}`;
16404
18126
  }
16405
18127
  if (idx >= 0) {
16406
18128
  const old = mounted.splice(idx, 1)[0];
16407
- await old.client.close().catch((e) => log27.debug("mcp close failed", e));
18129
+ await old.client.close().catch((e) => log32.debug("mcp close failed", e));
16408
18130
  }
16409
18131
  try {
16410
18132
  const m = await mountMcpServer(name, conf);
@@ -16432,7 +18154,7 @@ ${task}`;
16432
18154
  }
16433
18155
  const m = mounted.splice(idx, 1)[0];
16434
18156
  remountMcpTools();
16435
- await m.client.close().catch((e) => log27.debug("mcp close failed", e));
18157
+ await m.client.close().catch((e) => log32.debug("mcp close failed", e));
16436
18158
  err(dim(` removed "${name}"
16437
18159
  `));
16438
18160
  return;
@@ -16635,7 +18357,7 @@ ${task}`;
16635
18357
  try {
16636
18358
  return readdirSync4(join14(cwd, absDir.replace(/^\/+/, "")), { withFileTypes: true }).map((d) => ({ name: d.name, dir: d.isDirectory() }));
16637
18359
  } catch (e) {
16638
- log27.debug("completion readdir failed", absDir, e);
18360
+ log32.debug("completion readdir failed", absDir, e);
16639
18361
  return null;
16640
18362
  }
16641
18363
  };
@@ -16734,7 +18456,8 @@ ${task}`;
16734
18456
  };
16735
18457
  let prefill;
16736
18458
  let tick = 0;
16737
- const dispatchLine = async (line) => {
18459
+ const dispatchLine = async (line, opts) => {
18460
+ if (!opts?.id) takeCut(voiceIO, false);
16738
18461
  if (/^[!#/]/.test(line)) dispatchPending = false;
16739
18462
  history.unshift(line.replace(/\n+/g, " \u23CE "));
16740
18463
  remember(line.replace(/\n+/g, " \u23CE "));
@@ -16771,7 +18494,7 @@ ${out}
16771
18494
  return;
16772
18495
  }
16773
18496
  await refreshCatalogs().catch((e) => {
16774
- log27.debug("catalog refresh failed", e);
18497
+ log32.debug("catalog refresh failed", e);
16775
18498
  });
16776
18499
  const sk = skills.find((x) => x.name === name);
16777
18500
  if (sk) {
@@ -16791,7 +18514,7 @@ ${out}
16791
18514
  }
16792
18515
  const task = pendingImages.length ? `${line} ${pendingImages.map((p) => "@" + p).join(" ")}` : line;
16793
18516
  pendingImages.length = 0;
16794
- await turn(task);
18517
+ await turn(task, opts);
16795
18518
  if (goalCondition && !aborting && !exitRequested) {
16796
18519
  goalTurns++;
16797
18520
  persistGoal();
@@ -16800,6 +18523,11 @@ ${out}
16800
18523
  if (exitRequested) return "quit";
16801
18524
  };
16802
18525
  let voicePartial = "";
18526
+ let interjectNote = "";
18527
+ const ackedNote = (said) => said ? `
18528
+ [you already said "${said}" out loud to open this reply \u2014 do not acknowledge again; go straight on.]` : "";
18529
+ let voiceSeq = 0;
18530
+ let lastVoiceTurn;
16803
18531
  let partialRedraw = null;
16804
18532
  const startVoice = async (greet) => {
16805
18533
  if (voiceIO) return true;
@@ -16857,7 +18585,7 @@ ${out}
16857
18585
  voiceEchoEnd();
16858
18586
  if (phase === "speaking") err("\r\x1B[0J" + yellow(" \u270B interrupted\n"));
16859
18587
  },
16860
- onUtterance: (text) => {
18588
+ onUtterance: (text, meta) => {
16861
18589
  voicePartial = "";
16862
18590
  if (!text.trim()) return;
16863
18591
  if (matchVoiceCommand(text) === "off") {
@@ -16872,15 +18600,24 @@ ${out}
16872
18600
  })();
16873
18601
  return;
16874
18602
  }
16875
- const cut = voiceIO.takeInterruptedReply();
16876
- const note = !cut || cut.full.length - cut.heard.length <= 40 ? "" : cut.heard.trim() ? `
16877
- [the user interrupted you mid-speech \u2014 they only heard up to: "\u2026${cut.heard.slice(-80)}". Work any unheard essentials into your reply naturally, only if still relevant.]` : `
16878
- [the user interrupted you before hearing any of your previous reply \u2014 none of it landed; do not assume they got it.]`;
16879
- if (/^[!#/]/.test(text.trim()) || note || pendingImages.length) dx?.abortSpeculation();
18603
+ const replaces = replacedVoiceTurn(lastVoiceTurn, meta);
18604
+ const { reply: cut, delivery, heard } = takeCut(voiceIO, !replaces);
18605
+ if (delivery || cut) dx?.parkCutDelivery(delivery, !!cut, heard);
18606
+ const cutReply = cut && cut.full.length - cut.heard.length > 40 ? cut : null;
18607
+ const noteP = replaces ? replaces.note : !cutReply ? Promise.resolve("") : (instinct ? instinct.bargeIntent(text).catch((e) => {
18608
+ log32.warn(`barge intent failed \u2014 model decides: ${e.message}`);
18609
+ return void 0;
18610
+ }) : Promise.resolve(void 0)).then(async (intent) => interruptionNote(cutReply, intent) + ackedNote(await instinct?.ackFor(text)));
18611
+ const cutIn = replaces ? replaces.cutIn : interjectNote;
18612
+ interjectNote = "";
18613
+ const id = `v${++voiceSeq}`;
18614
+ lastVoiceTurn = { id, text, note: noteP, cutIn };
18615
+ if (replaces) replacedVoiceTurns.add(replaces.id);
18616
+ if (/^[!#/]/.test(text.trim()) || cutReply || cutIn || replaces || pendingImages.length) dx?.abortSpeculation();
16880
18617
  if (!/^[!#/]/.test(text.trim())) voiceIO.beginSpeech(true);
16881
18618
  err(`\r\x1B[K ${bold(cyan("\u{1F3A4} \u203A"))} ${text}
16882
18619
  `);
16883
- void dispatchLine(text + note).then(async (r) => {
18620
+ void noteP.then((note) => dispatchLine(text + note + cutIn, { id, replaces: replaces?.id })).then(async (r) => {
16884
18621
  if (r === "quit") {
16885
18622
  await voiceIO?.awaitIdle();
16886
18623
  editorRef?.abort();
@@ -16888,6 +18625,43 @@ ${out}
16888
18625
  }).finally(() => editorRef?.redrawNow());
16889
18626
  }
16890
18627
  });
18628
+ if (instinct) {
18629
+ const v = voiceIO, o = v.options;
18630
+ const { onPartial } = o;
18631
+ const onUtterance = o.onUtterance;
18632
+ const listening = () => voiceIO === v && v.state === "listening" && !v.audible;
18633
+ const cutIns = new CutInAbsorber();
18634
+ o.onPartial = (text) => {
18635
+ if (listening()) instinct.hear(text);
18636
+ onPartial(text);
18637
+ };
18638
+ bindOverlapInstinct(v, instinct, () => voiceIO === v);
18639
+ bindAckInstinct(v, instinct);
18640
+ o.onUtterance = (text, meta) => {
18641
+ instinct.reset();
18642
+ if (cutIns.absorbs(text, meta)) {
18643
+ voicePartial = "";
18644
+ voiceDiag?.push({ t: performance.now(), kind: "instinct_final_absorbed", text });
18645
+ return;
18646
+ }
18647
+ onUtterance(text, meta);
18648
+ };
18649
+ Object.assign(instinct.options, {
18650
+ // Engine blip path: throwaway TTS context, no floor claim. Private in bod-voice — upstream a public
18651
+ // `backchannel()` if this graduates from the spike. AEC only (the blip would deafen half-duplex).
18652
+ onBackchannel: () => {
18653
+ if (!listening() || !v.usingAec) return false;
18654
+ v.fireBackchannel();
18655
+ },
18656
+ onInterject: (heard) => {
18657
+ if (!listening()) return false;
18658
+ cutIns.interjected(heard);
18659
+ interjectNote = `
18660
+ [you are CUTTING IN \u2014 the user is still mid-sentence and has said only this so far. Do it the way a person would: one short natural interjection (a correction, the word they're reaching for, or the answer), then stop and let them continue.]`;
18661
+ onUtterance(heard);
18662
+ }
18663
+ });
18664
+ }
16891
18665
  {
16892
18666
  const vo = voiceIO.options;
16893
18667
  voiceDiag.push({
@@ -16905,9 +18679,11 @@ ${out}
16905
18679
  bargeIn: vo.bargeIn,
16906
18680
  overlapPause: vo.overlapPause,
16907
18681
  overlapResumeMs: vo.overlapResumeMs,
18682
+ overlapDecideMs: vo.overlapDecideMs,
16908
18683
  speculativeMs: vo.speculativeMs,
16909
18684
  backchannelMs: vo.backchannelMs,
16910
- emotions: vo.emotions
18685
+ emotions: vo.emotions,
18686
+ ackPick: !!vo.pickAck
16911
18687
  }
16912
18688
  });
16913
18689
  }