@livx.cc/agentx 0.99.105 → 0.99.106

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -683,16 +683,16 @@ ${entries.map((s) => " " + s).join("\n")}`);
683
683
  return blocks.length ? blocks.join("\n") : `(no ${label} found)`;
684
684
  }
685
685
  function fuzzyLineReplace(content, oldStr, newStr) {
686
- const norm2 = (s) => s.trim();
686
+ const norm4 = (s) => s.trim();
687
687
  const cl = content.split("\n");
688
- const ol = oldStr.split("\n").map(norm2);
688
+ const ol = oldStr.split("\n").map(norm4);
689
689
  while (ol.length && ol[ol.length - 1] === "") ol.pop();
690
690
  while (ol.length && ol[0] === "") ol.shift();
691
691
  if (!ol.length) return null;
692
692
  const matches = [];
693
693
  for (let i2 = 0; i2 + ol.length <= cl.length; i2++) {
694
694
  let ok = true;
695
- for (let j = 0; j < ol.length; j++) if (norm2(cl[i2 + j]) !== ol[j]) {
695
+ for (let j = 0; j < ol.length; j++) if (norm4(cl[i2 + j]) !== ol[j]) {
696
696
  ok = false;
697
697
  break;
698
698
  }
@@ -3149,7 +3149,7 @@ var init_tools_shell = __esm({
3149
3149
  });
3150
3150
 
3151
3151
  // src/ccBridge.ts
3152
- var CC_BRIDGED = /* @__PURE__ */ new Set(["Recall", "Remember", "MemorySearch"]);
3152
+ var CC_BRIDGED = /* @__PURE__ */ new Set(["Recall", "Remember", "MemorySearch", "Forget"]);
3153
3153
  function ccToolServer(name, tools, call) {
3154
3154
  const instance = {
3155
3155
  async connect(t) {
@@ -3508,8 +3508,9 @@ init_tools_structured();
3508
3508
  var MAX_INDEX = 25;
3509
3509
  var MAX_INDEX_LINES = 200;
3510
3510
  var MAX_INDEX_BYTES = 25e3;
3511
- var MEMORY_PROMPT = '## Memory guidelines\n**When to Remember:** user corrections ("no, do X instead"), preferences ("I prefer Y"), project constraints, recurring gotchas, role/context ("I\'m a data scientist"), workflow patterns confirmed by the user. Capture the WHY, not just the what.\n**When NOT to Remember:** task-specific scratch, transient state, things derivable from code/git, conversation filler, anything already in instruction files. If removing the memory wouldn\'t change future behavior, don\'t save it.\n**Types** (pass to Remember \u2014 this determines where the memory is stored):\n- `user` \u2014 role, preferences, knowledge level, collaboration style (GLOBAL \u2014 follows you across projects)\n- `feedback` \u2014 corrections, what to avoid/repeat, with WHY (GLOBAL)\n- `project` \u2014 ongoing work, goals, constraints, deadlines (LOCAL to this project; convert relative dates to absolute)\n- `reference` \u2014 pointers to external resources, URLs, vendor docs (LOCAL to this project)\n**Before acting on a recalled memory:** verify it\'s still true \u2014 a memory naming a file/function/flag is a claim from when it was written. Grep/read to confirm before recommending. Stale memory \u2192 update via Remember (same slug overwrites).\n**Dedup:** before writing, check if a similar memory exists (Recall or MemorySearch). Update the existing one rather than creating duplicates.\nCall `Recall` with a slug (or multiple slugs/a pattern) to load full bodies when relevant.\nCall `MemorySearch` with a query to find memories by content when you don\'t know the slug.\nCall `Remember` to persist a durable fact for future sessions.';
3512
- var VOICE_MEMORY_PROMPT = `You have Remember and Recall tools \u2014 use them directly, no delegation needed.
3511
+ var MEMORY_PROMPT = '## Memory guidelines\n**When to Remember:** user corrections ("no, do X instead"), preferences ("I prefer Y"), project constraints, recurring gotchas, role/context ("I\'m a data scientist"), workflow patterns confirmed by the user. Capture the WHY, not just the what.\n**When NOT to Remember:** task-specific scratch, transient state, things derivable from code/git, conversation filler, anything already in instruction files. If removing the memory wouldn\'t change future behavior, don\'t save it.\n**Types** (pass to Remember \u2014 this determines where the memory is stored):\n- `user` \u2014 role, preferences, knowledge level, collaboration style (GLOBAL \u2014 follows you across projects)\n- `feedback` \u2014 corrections, what to avoid/repeat, with WHY (GLOBAL)\n- `project` \u2014 ongoing work, goals, constraints, deadlines (LOCAL to this project; convert relative dates to absolute)\n- `reference` \u2014 pointers to external resources, URLs, vendor docs (LOCAL to this project)\n**Before acting on a recalled memory:** verify it\'s still true \u2014 a memory naming a file/function/flag is a claim from when it was written. Grep/read to confirm before recommending. Stale memory \u2192 update via Remember (same slug overwrites).\n**Forget:** when the user asks to remove/delete/forget a memory, call `Forget` with its slug \u2014 it deletes the fact and its index line. Never "remove" by re-Remembering a note that says it was removed: that keeps the memory.\n**Dedup:** before writing, check if a similar memory exists (Recall or MemorySearch). Update the existing one rather than creating duplicates.\nCall `Recall` with a slug (or multiple slugs/a pattern) to load full bodies when relevant.\nCall `MemorySearch` with a query to find memories by content when you don\'t know the slug.\nCall `Remember` to persist a durable fact for future sessions.';
3512
+ var VOICE_MEMORY_PROMPT = `You have Remember, Recall and Forget tools \u2014 use them directly, no delegation needed.
3513
+ When the user asks to remove/delete/forget something you remember, call Forget with its slug (MemorySearch first if unsure) \u2014 never Remember a "removed" note over it; only say it is gone after Forget confirms.
3513
3514
  IMPLICIT CAPTURE: when the user shares their name, role, a preference, a correction ("no, do X instead"), or a project constraint \u2014 call Remember immediately without announcing it. Natural memory, not a ceremony.
3514
3515
  For explicit "remember X" requests, also call Remember directly and confirm briefly ("got it").
3515
3516
  Do NOT remember: transient task details, conversation filler, things you'd forget in a real conversation.
@@ -3518,7 +3519,7 @@ For anything requiring files, shell, or web \u2014 still Act.`;
3518
3519
  async function loadMemory(fs, dir, opts = {}) {
3519
3520
  const dirs = (Array.isArray(dir) ? dir : [dir]).filter(Boolean);
3520
3521
  const writeDir = dirs[0];
3521
- const tools = [recallTool(fs, dirs), rememberTool(fs, writeDir, opts), memorySearchTool(fs, dirs)];
3522
+ const tools = [recallTool(fs, dirs), rememberTool(fs, writeDir, opts), memorySearchTool(fs, dirs), forgetTool(fs, [...dirs, ...opts.userDir ? [opts.userDir] : []])];
3522
3523
  const allPointers = [];
3523
3524
  const seenSlugs = /* @__PURE__ */ new Set();
3524
3525
  let header = "";
@@ -3760,6 +3761,39 @@ function rememberTool(fs, dir, memOpts = {}) {
3760
3761
  }
3761
3762
  };
3762
3763
  }
3764
+ async function forgetFact(fs, dirs, slug2) {
3765
+ const from = [];
3766
+ for (const d of [...new Set(dirs.filter(Boolean))]) {
3767
+ const path = `${d}/${slug2}.md`;
3768
+ const had = await fs.exists(path);
3769
+ if (had) await fs.deleteFile(path);
3770
+ const indexPath = `${d}/MEMORY.md`;
3771
+ let unindexed = false;
3772
+ if (await fs.exists(indexPath)) {
3773
+ const lines = (await fs.readFile(indexPath)).split("\n");
3774
+ const kept = lines.filter((l) => !l.includes(`(${slug2}.md)`));
3775
+ if (kept.length !== lines.length) {
3776
+ await fs.writeFile(indexPath, kept.join("\n"));
3777
+ unindexed = true;
3778
+ }
3779
+ }
3780
+ if (had || unindexed) from.push(d);
3781
+ }
3782
+ return from;
3783
+ }
3784
+ function forgetTool(fs, dirs) {
3785
+ return {
3786
+ name: "Forget",
3787
+ description: 'Delete a memory for good (its fact file and index line) when the user asks to remove, delete or forget it. Pass the slug (MemorySearch if unsure). Re-Remembering a "removed" note does NOT delete anything.',
3788
+ parameters: { type: "object", required: ["slug"], properties: { slug: { type: "string", description: "fact slug (filename without .md)" } } },
3789
+ async run({ slug: slug2 }, _ctx) {
3790
+ const s = cleanSlug(slug2);
3791
+ if (!s) return "Error: pass the slug to forget.";
3792
+ const from = await forgetFact(fs, dirs, s);
3793
+ return from.length ? `Forgot '${s}' \u2014 deleted from ${from.length} memory scope(s); it will not be recalled again.` : `Error: no memory '${s}' found (nothing deleted). Use MemorySearch to find the right slug.`;
3794
+ }
3795
+ };
3796
+ }
3763
3797
 
3764
3798
  // src/instructions.ts
3765
3799
  var DEFAULT_NAMES = ["AGENT.md", "AGENTS.md", "CLAUDE.md"];
@@ -5981,14 +6015,14 @@ function summarize(messages, focus) {
5981
6015
  if (lastAssistant) lines.push(`last assistant: ${lastAssistant}`);
5982
6016
  if (carried.length) lines.push("earlier recap:", ...carried.map((l) => ` ${l}`));
5983
6017
  if (focus?.trim()) {
5984
- const words = focus.toLowerCase().split(/\s+/).filter((w) => w.length > 2);
6018
+ const words3 = focus.toLowerCase().split(/\s+/).filter((w) => w.length > 2);
5985
6019
  const kept = [];
5986
6020
  const ordered = [...messages.filter((m) => !isSyntheticSummary(m)), ...messages.filter(isSyntheticSummary)];
5987
6021
  outer: for (const msg of ordered) {
5988
6022
  if (msg.role === "tool") continue;
5989
6023
  for (const line of contentText(msg.content).split("\n")) {
5990
6024
  const l = line.toLowerCase();
5991
- if (line.trim() && words.some((w) => l.includes(w))) {
6025
+ if (line.trim() && words3.some((w) => l.includes(w))) {
5992
6026
  kept.push(line.trim().slice(0, 200));
5993
6027
  if (kept.length >= 12) break outer;
5994
6028
  }
@@ -7121,13 +7155,13 @@ function isTranscriptComplete(text) {
7121
7155
  const trimmed = text.trim();
7122
7156
  if (!trimmed) return false;
7123
7157
  if (/[.?!]$/.test(trimmed)) return true;
7124
- const words = trimmed.toLowerCase().split(/\s+/);
7125
- const lastWord = words[words.length - 1];
7158
+ const words3 = trimmed.toLowerCase().split(/\s+/);
7159
+ const lastWord = words3[words3.length - 1];
7126
7160
  if (COMPLETE_ENDINGS.has(lastWord)) return true;
7127
7161
  if (INCOMPLETE_ENDINGS.has(lastWord)) return false;
7128
7162
  const lower = trimmed.toLowerCase();
7129
7163
  for (const marker of CONTINUATION_MARKERS) if (lower.endsWith(marker)) return false;
7130
- if (words.length <= 2) return false;
7164
+ if (words3.length <= 2) return false;
7131
7165
  return true;
7132
7166
  }
7133
7167
  var MAX_HOLD_MS = 2e3;
@@ -7528,13 +7562,13 @@ var VoiceEngine = class _VoiceEngine {
7528
7562
  };
7529
7563
  if (this.options.revealMode === "word" || this.options.heardTimestamps) {
7530
7564
  this.tts.wantTimestamps = true;
7531
- this.tts.onTimestamps = (words, start) => {
7565
+ this.tts.onTimestamps = (words3, start) => {
7532
7566
  if (this.interrupted) return;
7533
7567
  start.forEach((s, i) => {
7534
- if (this.wordAnchorPending) this.wordPending.push([words[i] ?? "", s]);
7568
+ if (this.wordAnchorPending) this.wordPending.push([words3[i] ?? "", s]);
7535
7569
  else {
7536
7570
  this.wordStarts.push(this.wordOrigin + s);
7537
- this.wordTexts.push(words[i] ?? "");
7571
+ this.wordTexts.push(words3[i] ?? "");
7538
7572
  }
7539
7573
  });
7540
7574
  };
@@ -7676,22 +7710,22 @@ var VoiceEngine = class _VoiceEngine {
7676
7710
  if (this.adaptiveAckFired && this.ackProbe !== null) {
7677
7711
  this.ackProbe += text;
7678
7712
  const probe = this.ackProbe;
7679
- const bare = probe.replace(/\[emotion[^\]]*\]/gi, "").replace(/^\s+/, "");
7680
- const m = bare.match(/^([^.!?…\n]*[.!?…\n])([\s\S]*)$/);
7681
- if (!m && bare.length < 80) return "";
7713
+ const bare2 = probe.replace(/\[emotion[^\]]*\]/gi, "").replace(/^\s+/, "");
7714
+ const m = bare2.match(/^([^.!?…\n]*[.!?…\n])([\s\S]*)$/);
7715
+ if (!m && bare2.length < 80) return "";
7682
7716
  this.ackProbe = null;
7683
7717
  this.adaptiveAckFired = false;
7684
- const lead = m ? m[1] : bare;
7685
- const norm2 = lead.replace(/[^a-z ]/gi, " ").replace(/\s+/g, " ").trim().toLowerCase();
7686
- const same = this.ackPickedFired ? bare.match(_VoiceEngine.leadingPhrase(this.lastAck)) : null;
7687
- if (_VoiceEngine.ACK_LEADING.test(norm2)) {
7718
+ const lead2 = m ? m[1] : bare2;
7719
+ const norm4 = lead2.replace(/[^a-z ]/gi, " ").replace(/\s+/g, " ").trim().toLowerCase();
7720
+ const same = this.ackPickedFired ? bare2.match(_VoiceEngine.leadingPhrase(this.lastAck)) : null;
7721
+ if (_VoiceEngine.ACK_LEADING.test(norm4)) {
7688
7722
  const rest = m ? m[2] : "";
7689
- this.diag("reflex_ack_suppressed", { lead: lead.trim() });
7723
+ this.diag("reflex_ack_suppressed", { lead: lead2.trim() });
7690
7724
  if (!rest.trim()) return "";
7691
7725
  text = rest;
7692
7726
  } else if (same) {
7693
7727
  this.diag("reflex_ack_suppressed", { lead: same[0].trim() });
7694
- text = bare.slice(same[0].length);
7728
+ text = bare2.slice(same[0].length);
7695
7729
  if (!text.trim()) return "";
7696
7730
  } else {
7697
7731
  text = probe;
@@ -7770,8 +7804,8 @@ var VoiceEngine = class _VoiceEngine {
7770
7804
  while (n < this.wordStarts.length && this.wordStarts[n] <= playedSec) n++;
7771
7805
  if (n !== this.revealedN) {
7772
7806
  this.revealedN = n;
7773
- const words = this.revealText.trim().split(/\s+/).filter(Boolean);
7774
- this.options.onReveal(words.slice(0, n).join(" "));
7807
+ const words3 = this.revealText.trim().split(/\s+/).filter(Boolean);
7808
+ this.options.onReveal(words3.slice(0, n).join(" "));
7775
7809
  }
7776
7810
  this.revealPoll = this.clock.setTimeout(tick, 60);
7777
7811
  };
@@ -7798,8 +7832,8 @@ var VoiceEngine = class _VoiceEngine {
7798
7832
  this.adaptiveAckFired = false;
7799
7833
  this.ackProbe = null;
7800
7834
  if (heldProbe) {
7801
- const norm2 = heldProbe.replace(/\[emotion[^\]]*\]/gi, "").replace(/[^a-z ]/gi, " ").replace(/\s+/g, " ").trim().toLowerCase();
7802
- if (_VoiceEngine.ACK_LEADING.test(norm2)) this.diag("reflex_ack_suppressed", { lead: heldProbe.trim() });
7835
+ const norm4 = heldProbe.replace(/\[emotion[^\]]*\]/gi, "").replace(/[^a-z ]/gi, " ").replace(/\s+/g, " ").trim().toLowerCase();
7836
+ if (_VoiceEngine.ACK_LEADING.test(norm4)) this.diag("reflex_ack_suppressed", { lead: heldProbe.trim() });
7803
7837
  else this.speakDelta(heldProbe);
7804
7838
  }
7805
7839
  if (!this.speaking) return;
@@ -8337,14 +8371,14 @@ var VoiceEngine = class _VoiceEngine {
8337
8371
  if (!prev) return next;
8338
8372
  if (!next) return prev;
8339
8373
  const pw = prev.split(/\s+/), nw = next.split(/\s+/);
8340
- const norm2 = _VoiceEngine.normWord;
8374
+ const norm4 = _VoiceEngine.normWord;
8341
8375
  if (pw.length >= 2 && pw.length <= nw.length) {
8342
8376
  let pfx = true;
8343
- for (let i = 0; i < pw.length - 1; i++) if (norm2(pw[i]) !== norm2(nw[i])) {
8377
+ for (let i = 0; i < pw.length - 1; i++) if (norm4(pw[i]) !== norm4(nw[i])) {
8344
8378
  pfx = false;
8345
8379
  break;
8346
8380
  }
8347
- if (pfx && norm2(nw[pw.length - 1]).startsWith(norm2(pw[pw.length - 1]))) return next;
8381
+ if (pfx && norm4(nw[pw.length - 1]).startsWith(norm4(pw[pw.length - 1]))) return next;
8348
8382
  }
8349
8383
  if (_VoiceEngine.restates(prev, next)) {
8350
8384
  this.diag("merge_restated", { dropped: prev.slice(0, 60) });
@@ -8353,7 +8387,7 @@ var VoiceEngine = class _VoiceEngine {
8353
8387
  const max = Math.min(pw.length, nw.length);
8354
8388
  for (let k = max; k > 0; k--) {
8355
8389
  let match = true;
8356
- for (let i = 0; i < k; i++) if (norm2(pw[pw.length - k + i]) !== norm2(nw[i])) {
8390
+ for (let i = 0; i < k; i++) if (norm4(pw[pw.length - k + i]) !== norm4(nw[i])) {
8357
8391
  match = false;
8358
8392
  break;
8359
8393
  }
@@ -8405,11 +8439,11 @@ var VoiceEngine = class _VoiceEngine {
8405
8439
  * race), never worse. Fillers ("mhm") deliberately don't count as replies — see speakFiller. */
8406
8440
  refinalizes(next) {
8407
8441
  const pw = this.lastDispatchWords, nw = next.trim().split(/\s+/);
8408
- const norm2 = _VoiceEngine.normWord;
8442
+ const norm4 = _VoiceEngine.normWord;
8409
8443
  if (pw.length < 3 || nw.length < pw.length) return false;
8410
8444
  if (this.clock.now() - this.lastDispatchAt >= _VoiceEngine.DUP_FINAL_MS) return false;
8411
- for (let i = 0; i < pw.length - 1; i++) if (norm2(pw[i]) !== norm2(nw[i])) return false;
8412
- const pLast = norm2(pw[pw.length - 1]), nSame = norm2(nw[pw.length - 1]);
8445
+ for (let i = 0; i < pw.length - 1; i++) if (norm4(pw[i]) !== norm4(nw[i])) return false;
8446
+ const pLast = norm4(pw[pw.length - 1]), nSame = norm4(nw[pw.length - 1]);
8413
8447
  if (!nSame.startsWith(pLast)) return false;
8414
8448
  return nw.length > pw.length || nSame.length > pLast.length;
8415
8449
  }
@@ -9241,7 +9275,98 @@ import { AIClient, DecideClient, JevAdapter, LlmDecideAdapter } from "ai.libx.js
9241
9275
  // src/voice/overlap.ts
9242
9276
  init_logging();
9243
9277
  var log20 = forComponent("Overlap");
9278
+ var norm2 = (s) => s.toLowerCase().replace(/[’']/g, "'").replace(/[^\p{L}\p{N}' -]+/gu, " ").replace(/\s+/g, " ").trim();
9279
+ var HARD = /\b(?:stop|wait|no|nope|nah|hold on|hang on|hold up|pause|cancel|quiet|enough|shut up|never ?mind|actually|but|sorry what|what|why|how|who|when|where|which|can you|could you|tell me|i mean|excuse me)\b/;
9244
9280
  var ACK = new Set("yeah yea yes yep yup ya ok okay k right sure true totally exactly definitely absolutely indeed mm mmm hmm mhm mm-hm uh-huh uh huh aha ah oh ooh wow nice cool great awesome perfect good fine interesting gotcha got it i see makes sense fair agreed correct of course for sure lol ha haha hahaha heh hehe yeah yeah alright all right sweet neat oh nice oh wow oh cool oh okay oh yeah oh right that's right that's true thats right thats true love it i know me too same".split(" "));
9281
+ var LEAD = /* @__PURE__ */ new Set(["and", "so", "um", "uh", "er", "well", "oh"]);
9282
+ var LEADER = /* @__PURE__ */ new Set(["yeah", "yea", "yes", "yep", "yup", "ok", "okay", "right", "sure", "alright", "mm", "mhm", "hmm", "uh-huh", "totally", "exactly", "true"]);
9283
+ var PHRASES = ["got it", "i see", "makes sense", "of course", "for sure", "all right", "that's right", "that's true", "thats right", "thats true", "love it", "i know", "me too", "uh huh"];
9284
+ function overlapRule(text) {
9285
+ if (/\?/.test(text)) return "cut";
9286
+ let t = norm2(text);
9287
+ if (!t) return void 0;
9288
+ if (HARD.test(t)) return "cut";
9289
+ for (const p of PHRASES) t = t.split(p).join(" ");
9290
+ const words3 = t.split(/[\s-]+/).filter(Boolean);
9291
+ const ack = (w) => ACK.has(w) || /^(?:ha)+h?$|^(?:he)+h?$|^m+(?:hm+)?$|^h?m+$/.test(w);
9292
+ if (words3.every(ack)) return "keep";
9293
+ const lead2 = words3.findIndex((w) => !LEAD.has(w));
9294
+ if (lead2 >= 0 && LEADER.has(words3[lead2]) && words3.slice(lead2).filter((w) => !ack(w) && !LEAD.has(w)).length <= 1) return "keep";
9295
+ return void 0;
9296
+ }
9297
+ function fastOverlapModel(ai, model = "groq/qwen/qwen3.8-27b") {
9298
+ return async (heard, signal) => {
9299
+ const prompt = 'Live voice call. The assistant is mid-sentence and the user just said this over it: "' + heard + '"\nIs it ONLY a listener cue \u2014 agreement, acknowledgement, a short reaction or laugh \u2014 so the assistant should keep talking? Answer no if they want the floor: a question, objection, correction, request, or new information.\nAnswer yes or no.';
9300
+ const reasoning = /qwen/.test(model) ? { providerOptions: { reasoning_effort: "none" } } : /gpt-oss/.test(model) ? { providerOptions: { reasoning_effort: "low" } } : {};
9301
+ const r = await ai.chat({ model, messages: [{ role: "user", content: prompt }], stream: false, signal, temperature: 0, maxTokens: /gpt-oss/.test(model) ? 200 : 3, ...reasoning });
9302
+ const a = (r.content ?? "").trim().toLowerCase();
9303
+ return /^yes/.test(a) ? "keep" : /^no/.test(a) ? "cut" : null;
9304
+ };
9305
+ }
9306
+ var OverlapJudgeOptions = class {
9307
+ /** The fast model for ambiguous overlaps; absent → ambiguous cuts. */
9308
+ model;
9309
+ /** A model verdict later than this is dropped → cut. 600ms: groq qwen measured p50 ~100ms / max ~230ms in isolation
9310
+ * (24 calls, 2026-10-14) but live, beside the reflex + chatty-channel calls, 2 of 2 ambiguous verdicts missed 300ms
9311
+ * and cut the reply. The engine holds the overlap up to 900ms (bindOverlapInstinct) — a slightly later keep beats
9312
+ * a cut, so the budget sits well inside that hold. */
9313
+ budgetMs = 600;
9314
+ onDecision = () => {
9315
+ };
9316
+ now = () => performance.now();
9317
+ };
9318
+ var OverlapJudge = class {
9319
+ constructor(options) {
9320
+ this.options = options;
9321
+ this.options = { ...new OverlapJudgeOptions(), ...options };
9322
+ }
9323
+ options;
9324
+ get o() {
9325
+ return this.options;
9326
+ }
9327
+ async judge(heard) {
9328
+ const t0 = this.o.now();
9329
+ const done = (verdict, by) => {
9330
+ const d = { verdict, by, ms: Math.round(this.o.now() - t0) };
9331
+ if (by === "late" || by === "error" || by === "none") log20.warn(`overlap "${heard.slice(0, 60)}": no verdict (${by}, ${d.ms}ms) \u2192 cut`);
9332
+ else log20.debug(`overlap "${heard.slice(0, 60)}": ${verdict} by ${by} ${d.ms}ms`);
9333
+ this.o.onDecision(heard, d);
9334
+ return d;
9335
+ };
9336
+ const rule = overlapRule(heard);
9337
+ if (rule) return done(rule, "rule");
9338
+ if (!this.o.model) return done("cut", "none");
9339
+ const ctl = new AbortController();
9340
+ let timer;
9341
+ const late = new Promise((r) => {
9342
+ timer = setTimeout(() => {
9343
+ ctl.abort();
9344
+ r("late");
9345
+ }, this.o.budgetMs);
9346
+ });
9347
+ try {
9348
+ const v = await Promise.race([this.o.model(heard, ctl.signal).catch((e) => {
9349
+ if (!ctl.signal.aborted) log20.warn(`overlap model failed: ${e.message}`);
9350
+ return "error";
9351
+ }), late]);
9352
+ if (v === "late" || v === "error") return done("cut", v);
9353
+ return v ? done(v, "model") : done("cut", "error");
9354
+ } finally {
9355
+ clearTimeout(timer);
9356
+ }
9357
+ }
9358
+ };
9359
+ function overlapModelFor(ai, env = process.env) {
9360
+ const model = env.AGENTX_CHANNEL_DECIDER_MODEL || void 0;
9361
+ if (!model && !env.GROQ_API_KEY) return void 0;
9362
+ const m = fastOverlapModel(ai, model);
9363
+ const t0 = performance.now();
9364
+ void m("yeah", AbortSignal.timeout(1e4)).then(
9365
+ () => log20.debug(`fast model warm in ${Math.round(performance.now() - t0)}ms`),
9366
+ (e) => log20.warn(`fast model warm-up failed (the first overlap verdict may be late): ${e?.message ?? e}`)
9367
+ );
9368
+ return m;
9369
+ }
9245
9370
 
9246
9371
  // src/voice/instinct.ts
9247
9372
  var log21 = forComponent("Instinct");
@@ -9257,6 +9382,17 @@ var ACK_SITUATIONS = {
9257
9382
  command: 'a request or instruction for you to do, find, check or tell them something ("tell me the news", "check my calendar")',
9258
9383
  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")'
9259
9384
  };
9385
+ var CHANNEL_ACTIONS = {
9386
+ silence: 'the default \u2014 nothing to add. ALWAYS for a greeting, thanks, a yes/no, or any short complete turn the real reply will answer instantly ("hey", "how are you?", "thanks"); also mid-word or mid-clause',
9387
+ backchannel: 'they are mid-way through a longer story or explanation (several clauses) and just closed a clause: a tiny "mm-hm"/"right" shows you follow, without taking the turn',
9388
+ filler: 'they paused mid-thought, searching for words, and a soft "hmm" keeps the line warm',
9389
+ bridge: 'they finished a substantial request or hard question and the real answer takes a moment: a short "let me check\u2014" lead-in'
9390
+ };
9391
+ var CHANNEL_BANK = {
9392
+ backchannel: ["Mm-hm.", "Right.", "Mm, yeah.", "Uh-huh.", "I see.", "Mm."],
9393
+ filler: ["Hmm\u2026", "Mm\u2026", "Hm, mm\u2026", "Ahh\u2026"],
9394
+ bridge: ["Let me check\u2014", "One sec, looking\u2014", "Hm, let me see\u2014", "Okay, give me a moment\u2014", "Let me think on that\u2014"]
9395
+ };
9260
9396
  var INTENT_ACK_OVER = {
9261
9397
  stop: Object.keys(ACK_SITUATIONS).filter((s) => s !== "silent"),
9262
9398
  steer: ["command"],
@@ -9330,6 +9466,34 @@ async function unsaidClauses(pieces, spoken, decider, timeoutMs = 5e3) {
9330
9466
  }
9331
9467
  }
9332
9468
 
9469
+ // src/voice/spelling.ts
9470
+ var GROUP = String.raw`(?:\b[A-Za-z](?:-[A-Za-z]){1,}\b|\b[A-Z](?: [A-Z]){2,}\b)`;
9471
+ var SEP = String.raw`\s*(?:\b[Dd]ot\b|\b[Pp]eriod\b|\.)\s*`;
9472
+ var RUN = new RegExp(`${GROUP}(?:${SEP}${GROUP})*`, "g");
9473
+ function spelledOut(text) {
9474
+ const out = [];
9475
+ for (const m of text.matchAll(RUN)) {
9476
+ const raw = m[0];
9477
+ if (!/^[A-Za-z][- ]/.test(raw)) continue;
9478
+ const parts = raw.split(new RegExp(SEP)).map((g) => g.replace(/[- ]/g, ""));
9479
+ const word = parts.join(".");
9480
+ if (word.replace(/\./g, "").length < 3) continue;
9481
+ const s = parts.length > 1 ? word.toLowerCase() : word;
9482
+ if (!out.includes(s)) out.push(s);
9483
+ }
9484
+ return out;
9485
+ }
9486
+ function spellingNote(text) {
9487
+ const w = spelledOut(text);
9488
+ return w.length ? `
9489
+ [spelled out letter by letter: ${w.map((x) => `"${x}"`).join(", ")} \u2014 that spelling is exact and replaces the word you heard]` : "";
9490
+ }
9491
+
9492
+ // src/voice/channelLang.ts
9493
+ init_logging();
9494
+ import { mkdir, readFile, writeFile } from "fs/promises";
9495
+ import { join as join5 } from "path";
9496
+
9333
9497
  // src/voice/leak.ts
9334
9498
  var TOOL = String.raw`(?:QuickLook|TaskStatus|CancelTask|AnswerTask|ExitSession|Act|Think|Hold(?! on\b))`;
9335
9499
  var SUSPECT = [
@@ -9407,13 +9571,480 @@ function scrubSpokenJunk(text, final = false) {
9407
9571
  return s;
9408
9572
  }
9409
9573
  function isRepeatOf(sentence, spoken) {
9410
- const norm2 = (x) => x.replace(/\[[a-z_ ]+\]/gi, "").replace(/[^\p{L}\p{N}]+/gu, " ").trim().toLowerCase();
9411
- const s = norm2(sentence);
9412
- return s.replace(/ /g, "").length >= 16 && spoken.split(SENTENCE_SPLIT).some((x) => norm2(x) === s);
9574
+ const norm4 = (x) => x.replace(/\[[a-z_ ]+\]/gi, "").replace(/[^\p{L}\p{N}]+/gu, " ").trim().toLowerCase();
9575
+ const s = norm4(sentence);
9576
+ return s.replace(/ /g, "").length >= 16 && spoken.split(SENTENCE_SPLIT).some((x) => norm4(x) === s);
9413
9577
  }
9578
+ var LEAD_TAGS = String.raw`^(?:\s*\[[^\]]*\]\s*)*\s*`;
9579
+ var RESULT_CLAIM_RE = new RegExp(LEAD_TAGS + String.raw`(?:(?:all\s+)?done|finished|(?:i\s+)?found|i(?:['’]ve|\s+have)?\s+(?:found|checked|looked|scanned|searched)|there\s+(?:are|is|were|was)\s+no|no\s+(?:files?|results?|matches|sessions?))\b`, "i");
9580
+ var DONE_CLAIM_RE = new RegExp(LEAD_TAGS + String.raw`(?:(?:all\s+)?done|finished|it['’]?s\s+(?:done|finished)|completed?)\b`, "i");
9581
+
9582
+ // src/voice/channelLang.ts
9583
+ var log23 = forComponent("ChannelBanks");
9584
+ var LINE_KINDS = ["backchannel", "filler", "bridge"];
9585
+ var BANK_KEYS = [...LINE_KINDS, "phatic"];
9586
+ var EN_PHATIC = [
9587
+ "hey",
9588
+ "hi",
9589
+ "hello",
9590
+ "yo",
9591
+ "hiya",
9592
+ "morning",
9593
+ "good morning",
9594
+ "good evening",
9595
+ "good afternoon",
9596
+ "thanks",
9597
+ "thank you",
9598
+ "ok",
9599
+ "okay",
9600
+ "yes",
9601
+ "yeah",
9602
+ "yep",
9603
+ "no",
9604
+ "nope",
9605
+ "sure",
9606
+ "bye",
9607
+ "goodbye",
9608
+ "how are you",
9609
+ "what's up",
9610
+ "whats up",
9611
+ "can you hear me"
9612
+ ];
9613
+ var EN_BANK = { ...CHANNEL_BANK, phatic: EN_PHATIC };
9614
+ var HE_BANK = {
9615
+ backchannel: ["\u05D0\u05D4\u05D4", "\u05DB\u05DF", "\u05E0\u05DB\u05D5\u05DF", "\u05D0\u05D5\u05E7\u05D9\u05D9", "\u05DE\u05DE", "\u05D1\u05E8\u05D5\u05E8", "\u05D5\u05D5\u05D0\u05D5", "\u05D9\u05E4\u05D4", "\u05D1\u05D8\u05D7", "\u05D0\u05D4, \u05DB\u05DF"],
9616
+ filler: ["\u05DE\u05DE\u2026", "\u05D0\u05D4\u05D4\u2026", "\u05DB\u05DF\u2026", "\u05D0\u05DE\u05DE\u2026", "\u05DE\u05DE, \u05DB\u05DF\u2026", "\u05D0\u05D5\u05E7\u05D9\u05D9\u2026"],
9617
+ bridge: ["\u05E8\u05D2\u05E2\u2026", "\u05E9\u05E0\u05D9\u05D9\u05D4\u2026", "\u05E8\u05E7 \u05E9\u05E0\u05D9\u05D9\u05D4\u2026", "\u05E8\u05D2\u05E2 \u05D0\u05D7\u05D3\u2026", "\u05DE\u05D9\u05D3\u2026", "\u05E2\u05D5\u05D3 \u05E8\u05D2\u05E2\u2026", "\u05D0\u05D5\u05E7\u05D9\u05D9, \u05E8\u05D2\u05E2\u2026", "\u05E9\u05E0\u05D9\u05D9\u05D4, \u05E8\u05D2\u05E2\u2026"],
9618
+ phatic: [
9619
+ "\u05D4\u05D9\u05D9",
9620
+ "\u05D4\u05D9",
9621
+ "\u05E9\u05DC\u05D5\u05DD",
9622
+ "\u05D0\u05D4\u05DC\u05DF",
9623
+ "\u05DE\u05D4 \u05E0\u05E9\u05DE\u05E2",
9624
+ "\u05DE\u05D4 \u05E7\u05D5\u05E8\u05D4",
9625
+ "\u05EA\u05D5\u05D3\u05D4",
9626
+ "\u05EA\u05D5\u05D3\u05D4 \u05E8\u05D1\u05D4",
9627
+ "\u05DB\u05DF",
9628
+ "\u05DC\u05D0",
9629
+ "\u05D0\u05D5\u05E7\u05D9\u05D9",
9630
+ "\u05D1\u05E1\u05D3\u05E8",
9631
+ "\u05D1\u05D9\u05D9",
9632
+ "\u05DC\u05D4\u05EA\u05E8\u05D0\u05D5\u05EA",
9633
+ "\u05D1\u05D5\u05E7\u05E8 \u05D8\u05D5\u05D1",
9634
+ "\u05E2\u05E8\u05D1 \u05D8\u05D5\u05D1",
9635
+ "\u05D0\u05EA\u05D4 \u05E9\u05D5\u05DE\u05E2 \u05D0\u05D5\u05EA\u05D9",
9636
+ "\u05D0\u05EA \u05E9\u05D5\u05DE\u05E2\u05EA \u05D0\u05D5\u05EA\u05D9"
9637
+ ]
9638
+ };
9639
+ var primaryLang = (tag) => tag.trim().toLowerCase().split(/[-_]/)[0];
9640
+ function isPhatic(text, bank) {
9641
+ const t = norm3(text);
9642
+ return !!t && !!bank?.phatic?.some((p) => {
9643
+ const q = norm3(p);
9644
+ return !!q && (t === q || t.startsWith(q + " "));
9645
+ });
9646
+ }
9647
+ var SKIP = new Set("um uh uhm erm hmm mm oh ok okay so no yes yeah yep well hey hi and but right alright actually like now then wait hang on me speaker \u05D0\u05D5\u05E7\u05D9\u05D9 \u05D8\u05D5\u05D1 \u05D0\u05D6 \u05DC\u05D0 \u05DB\u05DF \u05E8\u05D2\u05E2 \u05D0\u05D4 \u05D0\u05DE \u05D9\u05D0\u05DC\u05DC\u05D4 \u05D5".split(" "));
9648
+ var ASK = "what whats how why where when who which whose is are was were do does did can could would will should shall have has may isnt arent dont doesnt didnt cant wont check open show tell find search look send run go lets please take give make write explain select focus order try compare list use set activate highlight outline capture start stop read play call book create add remove delete put type click close switch help get summarize translate mark keep remind schedule \u05DE\u05D4 \u05D0\u05D9\u05DA \u05DC\u05DE\u05D4 \u05D0\u05D9\u05E4\u05D4 \u05DE\u05EA\u05D9 \u05DE\u05D9 \u05D4\u05D0\u05DD \u05DB\u05DE\u05D4 \u05EA\u05DF \u05EA\u05E0\u05D9 \u05EA\u05D2\u05D9\u05D3 \u05EA\u05D2\u05D9\u05D3\u05D9 \u05D1\u05D5\u05D0 \u05D1\u05D5\u05D0\u05D9".split(" ");
9649
+ var WANT = /^(i (want|need|would like|d like|wanna)|אני (רוצה|צריך|צריכה|מבקש|מבקשת))(\s|$)/u;
9650
+ var lead = (text) => {
9651
+ const w = text.toLowerCase().replace(/[^\p{L}\p{N}\s]+/gu, "").split(/\s+/).filter(Boolean);
9652
+ let i = 0;
9653
+ while (i < w.length && (SKIP.has(w[i]) || /^\d+$/.test(w[i]))) i++;
9654
+ return w.slice(i);
9655
+ };
9656
+ function requestOpener(text) {
9657
+ const w = lead(text);
9658
+ if (!w.length) return false;
9659
+ return ASK.includes(w[0]) || WANT.test(w.slice(0, 4).join(" ")) || new RegExp("^\u05EA\\p{Script=Hebrew}{2,}$", "u").test(w[0]);
9660
+ }
9661
+ function pendingQuestion(text, lastWords = 6) {
9662
+ const all = text.toLowerCase().replace(/[^\p{L}\p{N}'\s.!?]+/gu, " ");
9663
+ const sentence = all.split(/[.!?]/).at(-1).split(/\s+/).filter(Boolean);
9664
+ const t = sentence.slice(-lastWords).join(" ");
9665
+ return /\b(can|could|would|will|do|does|did|is|are|should)\s+(you|we|i|it|there|they|he|she)\b/.test(t) || /(^|\s)(האם|אתה יכול|את יכולה)(\s|$)/u.test(t) || /\b(what|why|how|where|when|who|which)\s+(do|does|did|is|are|should|would|can|could|will)\s+(you|we|i|it|they|he|she)\b/.test(sentence.slice(-4 * lastWords).join(" "));
9666
+ }
9667
+ var SCRIPTS = [
9668
+ [new RegExp("\\p{Script=Hebrew}", "gu"), "he"],
9669
+ [new RegExp("\\p{Script=Arabic}", "gu"), "ar"],
9670
+ [new RegExp("\\p{Script=Cyrillic}", "gu"), "ru"],
9671
+ [new RegExp("\\p{Script=Greek}", "gu"), "el"],
9672
+ [/[\p{Script=Hiragana}\p{Script=Katakana}]/gu, "ja"],
9673
+ [new RegExp("\\p{Script=Hangul}", "gu"), "ko"],
9674
+ [new RegExp("\\p{Script=Han}", "gu"), "zh"],
9675
+ [new RegExp("\\p{Script=Thai}", "gu"), "th"],
9676
+ [new RegExp("\\p{Script=Devanagari}", "gu"), "hi"],
9677
+ [new RegExp("\\p{Script=Latin}", "gu"), "latin"]
9678
+ ];
9679
+ var STOP3 = Object.fromEntries(Object.entries({
9680
+ en: "hi hello hey yes no a thanks let see check one sec just give right well look the and is are you i it to of that what this was have with for not but my me",
9681
+ // 'a'/'no' are English too: without them 'thanks a lot', 'long time no see' failed fitsLanguage('en')
9682
+ es: "hola s\xED gracias bueno a me te lo se le del al el la los las que es y de en un una por para con no pero mi yo qu\xE9 est\xE1",
9683
+ fr: "bonjour salut oui merci le la les est et de des un une que je tu il pas pour avec mais mon qui",
9684
+ de: "hallo ja danke der die das und ist ich du nicht ein eine mit zu was aber mein wie auch",
9685
+ it: "ciao grazie s\xEC a mi ti si il lo la gli che \xE8 e di un una non per con ma mio io sono",
9686
+ pt: "ol\xE1 oi sim obrigado obrigada o a os as que \xE9 e de em um uma n\xE3o para com mas meu eu voc\xEA",
9687
+ nl: "hoi dank de het een en is ik je niet van dat met voor maar mijn wat"
9688
+ }).map(([k, v]) => [k, new Set(v.split(" "))]));
9689
+ var scriptOf = (w) => SCRIPTS.find(([re]) => {
9690
+ re.lastIndex = 0;
9691
+ return re.test(w);
9692
+ })?.[1] ?? null;
9693
+ function detectLanguage(text, switching = false) {
9694
+ const ws = text.toLowerCase().match(new RegExp("\\p{L}+", "gu")) ?? [];
9695
+ const n = /* @__PURE__ */ new Map();
9696
+ for (const w of ws) {
9697
+ const s = scriptOf(w);
9698
+ if (s) n.set(s, (n.get(s) ?? 0) + 1);
9699
+ }
9700
+ const [best, c] = [...n].sort((a2, b2) => b2[1] - a2[1])[0] ?? [null, 0];
9701
+ if (best !== "latin") return c >= 1 && (best === "zh" || best === "ja" || c >= 2 || ws.length === 1 && !switching) ? best : null;
9702
+ const score = Object.entries(STOP3).map(([l2, s]) => [l2, ws.filter((w) => s.has(w)).length]).sort((a2, b2) => b2[1] - a2[1]);
9703
+ const [[l, a], [, b]] = score;
9704
+ if (switching) return a - b >= 2 && ws.length >= 4 ? l : null;
9705
+ return a > b && (a >= 2 || a === 1 && ws.length <= 3) ? l : null;
9706
+ }
9707
+ var NAMES = new Intl.DisplayNames(["en"], { type: "language" });
9708
+ var languageName = (lang) => {
9709
+ try {
9710
+ return NAMES.of(lang) ?? lang;
9711
+ } catch {
9712
+ return lang;
9713
+ }
9714
+ };
9715
+ function fitsLanguage(line, lang) {
9716
+ const own = SCRIPTS.find(([, l]) => l === lang)?.[0];
9717
+ if (own) return (line.match(own)?.length ?? 0) > 0 && !new RegExp("\\p{Script=Latin}{2,}", "u").test(line);
9718
+ if (/[^\p{Script=Latin}\p{M}\p{P}\p{Z}\p{N}\p{S}]/u.test(line)) return false;
9719
+ const mine = STOP3[lang];
9720
+ const ws = line.toLowerCase().match(new RegExp("\\p{L}+", "gu")) ?? [];
9721
+ return !ws.some((w) => !mine?.has(w) && Object.entries(STOP3).some(([l, s]) => l !== lang && s.has(w)));
9722
+ }
9723
+ var norm3 = (s) => s.toLowerCase().replace(/[^\p{L}\p{N}' ]/gu, "").replace(/\s+/g, " ").trim();
9724
+ function cleanLine(s, lang, maxWords = 6) {
9725
+ const raw = s.trim().split("\n")[0];
9726
+ const l = raw.replace(/^[-*•\d.)\s]+/, "").replace(/^["'“”«»]+|["'“”«»]+$/g, "").replace(new RegExp("^\\p{L}+:\\s*", "u"), "").trim();
9727
+ if (!l || isLeakSuspect(raw) || isLeakSuspect(l)) {
9728
+ if (l) log23.debug(`line "${raw}" reads as meta \u2014 rejected`);
9729
+ return "";
9730
+ }
9731
+ if (lang && !fitsLanguage(l, lang)) {
9732
+ log23.debug(`line "${l}" is not ${lang} \u2014 rejected`);
9733
+ return "";
9734
+ }
9735
+ return l.split(/\s+/).length > maxWords || l.length > 60 ? "" : l;
9736
+ }
9737
+ var SPEAKER_FILLER = /^(?:so|well|actually|basically|anyway|anyways|like|you know|i mean|kind of|sort of|literally|honestly|and|but|because|then|the thing is)\b/i;
9738
+ function validateBank(raw, lang, minPerKind = 3) {
9739
+ if (!raw || typeof raw !== "object") return null;
9740
+ const out = {};
9741
+ for (const k of BANK_KEYS) {
9742
+ const v = raw[k];
9743
+ const seen = /* @__PURE__ */ new Set();
9744
+ out[k] = (Array.isArray(v) ? v : []).filter((x) => typeof x === "string").map((x) => cleanLine(x, lang)).filter((x) => x && !(k === "filler" && SPEAKER_FILLER.test(norm3(x))) && !seen.has(norm3(x)) && seen.add(norm3(x)));
9745
+ if (out[k].length < minPerKind) {
9746
+ log23.warn(`bank ${lang}: only ${out[k].length} valid ${k} lines \u2014 rejected`);
9747
+ return null;
9748
+ }
9749
+ }
9750
+ return out;
9751
+ }
9752
+ var KIND_HINT = {
9753
+ backchannel: 'tiny listener cues while they keep talking (like "mm-hm", "right") \u2014 never takes the turn',
9754
+ filler: 'soft LISTENER sounds while they pause mid-thought (like "mm\u2026", "mhm", "yeah\u2026") \u2014 never a word that continues or completes their sentence ("kind of", "basically", "you know") and never the start of yours',
9755
+ bridge: `neutral one-moment cues while the answer is prepared (like "let me see\u2026", "one sec\u2026") \u2014 never the start of a statement or opinion ("the thing is", "well actually", "here's the deal")`,
9756
+ phatic: 'greetings, thanks and yes/no openers a person starts a turn with (like "hi", "thanks", "how are you")'
9757
+ };
9758
+ function fastBankGenerator(ai, model, perKind = 12) {
9759
+ return async (lang, avoid, signal) => {
9760
+ const name = languageName(lang);
9761
+ const prompt = `Write short spoken side-sounds for a live voice conversation, in ${name} (natural, casual, how native ${name} speakers really talk \u2014 not translated English). ${perKind} varied lines per kind, 1-4 words each \u2014 short, idiomatic, grammatical everyday phrases a native would actually say:
9762
+ ${BANK_KEYS.map((k) => `- ${k}: ${KIND_HINT[k]}`).join("\n")}
9763
+ ` + (avoid.length ? `Do not repeat these existing ones: ${avoid.slice(0, 60).join(" | ")}
9764
+ ` : "") + `Reply with JSON only: {"backchannel":[...],"filler":[...],"bridge":[...],"phatic":[...]}. Every line in ${name}; no explanations.`;
9765
+ const reasoning = /gpt-oss/.test(model) ? { providerOptions: { reasoning_effort: "low" } } : {};
9766
+ const r = await ai.chat({ model, messages: [{ role: "user", content: prompt }], stream: false, signal, maxTokens: 2e3, ...reasoning });
9767
+ const m = (r.content ?? "").match(/\{[\s\S]*\}/);
9768
+ if (!m) throw new Error(`no JSON in bank reply: ${(r.content ?? "").slice(0, 80)}`);
9769
+ return JSON.parse(m[0]);
9770
+ };
9771
+ }
9772
+ var ChannelBanksOptions = class {
9773
+ /** Raw bank for a language (fastBankGenerator). Absent → English fallback only. */
9774
+ generate;
9775
+ /** Disk cache dir (one `<lang>.json` per language). Absent → in-memory only. */
9776
+ cacheDir;
9777
+ /** A cached bank older than this is still used, and topped up by one background call. */
9778
+ refreshMs = 24 * 36e5;
9779
+ /** Lines kept per kind (newest win) — the cap on growth. */
9780
+ maxPerKind = 40;
9781
+ timeoutMs = 2e4;
9782
+ /** After a failed cold generation, the next attempt waits this long. */
9783
+ retryMs = 6e4;
9784
+ /** Cold-start/offline banks (English only by default). */
9785
+ fallback = { en: EN_BANK };
9786
+ /** Hand-checked banks: used as-is for their language — never generated, cached or topped up. */
9787
+ curated = { he: HE_BANK };
9788
+ now = () => Date.now();
9789
+ };
9790
+ var BANK_VERSION = 2;
9791
+ var ChannelBanks = class {
9792
+ constructor(options) {
9793
+ this.options = options;
9794
+ this.options = { ...new ChannelBanksOptions(), ...options };
9795
+ }
9796
+ options;
9797
+ banks = /* @__PURE__ */ new Map();
9798
+ pending = /* @__PURE__ */ new Map();
9799
+ get o() {
9800
+ return this.options;
9801
+ }
9802
+ /** The bank to speak from for `lang` now — generated/cached, else the fallback, else null (= stay silent). */
9803
+ get(lang) {
9804
+ return this.o.curated[lang] ?? this.banks.get(lang) ?? this.o.fallback[lang] ?? null;
9805
+ }
9806
+ /** Load (cache) and/or generate `lang`'s bank in the background. Never throws, never blocks the caller. */
9807
+ ensure(lang) {
9808
+ if (this.o.curated[lang]) return Promise.resolve();
9809
+ if (!this.pending.has(lang)) this.pending.set(lang, this.load(lang));
9810
+ return this.pending.get(lang);
9811
+ }
9812
+ file(lang) {
9813
+ return this.o.cacheDir ? join5(this.o.cacheDir, `${lang.replace(/[^\w-]/g, "")}.json`) : null;
9814
+ }
9815
+ async load(lang) {
9816
+ const f = this.file(lang);
9817
+ let cached = null;
9818
+ if (f) cached = await readFile(f, "utf8").then((s) => JSON.parse(s)).catch((e) => {
9819
+ if (e.code !== "ENOENT") log23.warn(`bank cache ${f} unreadable \u2014 regenerating: ${e.message}`);
9820
+ return null;
9821
+ });
9822
+ if (cached && cached.v !== BANK_VERSION) {
9823
+ log23.debug(`bank ${lang}: cache v${cached.v ?? 1} predates v${BANK_VERSION} \u2014 regenerating`);
9824
+ cached = null;
9825
+ }
9826
+ const valid = cached && validateBank(cached.bank, lang, 0);
9827
+ const complete = !!valid && BANK_KEYS.every((k) => valid[k].length);
9828
+ if (valid) {
9829
+ for (const k of BANK_KEYS) if (!valid[k].length) valid[k] = [...this.o.fallback[lang]?.[k] ?? []];
9830
+ this.banks.set(lang, valid);
9831
+ log23.debug(`bank ${lang}: cache hit (${BANK_KEYS.map((k) => valid[k].length).join("/")})${complete ? "" : " \u2014 missing kinds, refreshing"}`);
9832
+ }
9833
+ if (complete && this.o.now() - cached.at < this.o.refreshMs) return;
9834
+ await this.generate(lang, valid);
9835
+ }
9836
+ async generate(lang, have) {
9837
+ if (!this.o.generate) return;
9838
+ const ac = new AbortController();
9839
+ const t = setTimeout(() => ac.abort(), this.o.timeoutMs);
9840
+ const t0 = this.o.now();
9841
+ try {
9842
+ const fresh = validateBank(await this.o.generate(lang, have ? BANK_KEYS.flatMap((k) => have[k]) : [], ac.signal), lang);
9843
+ if (!fresh) return;
9844
+ const bank = {};
9845
+ for (const k of BANK_KEYS) {
9846
+ const seen = /* @__PURE__ */ new Set();
9847
+ bank[k] = [...fresh[k], ...have?.[k] ?? []].filter((x) => !seen.has(norm3(x)) && seen.add(norm3(x))).slice(0, this.o.maxPerKind);
9848
+ }
9849
+ this.banks.set(lang, bank);
9850
+ log23.debug(`bank ${lang}: ${have ? "refreshed" : "generated"} (${BANK_KEYS.map((k) => bank[k].length).join("/")}) in ${Math.round(this.o.now() - t0)}ms`);
9851
+ const f = this.file(lang);
9852
+ if (f) await mkdir(this.o.cacheDir, { recursive: true }).then(() => writeFile(f, JSON.stringify({ at: this.o.now(), v: BANK_VERSION, bank }))).catch((e) => log23.warn(`bank cache write ${f} failed: ${e.message}`));
9853
+ } catch (e) {
9854
+ log23.warn(`bank ${lang}: generation failed \u2014 ${have ? "keeping the cached bank" : lang in this.o.fallback ? "fallback bank" : "silent"}: ${e.message}`);
9855
+ } finally {
9856
+ clearTimeout(t);
9857
+ if (!this.banks.has(lang)) setTimeout(() => this.pending.delete(lang), this.o.retryMs).unref?.();
9858
+ }
9859
+ }
9860
+ };
9861
+
9862
+ // src/voice/turnGate.ts
9863
+ var MID_PHRASE_WORDS = /* @__PURE__ */ new Set([
9864
+ "slash",
9865
+ "backslash",
9866
+ "dash",
9867
+ "hyphen",
9868
+ "dot",
9869
+ "underscore",
9870
+ "colon",
9871
+ "comma",
9872
+ "and",
9873
+ "or",
9874
+ "but",
9875
+ "so",
9876
+ "because",
9877
+ "if",
9878
+ "then",
9879
+ "called",
9880
+ "named",
9881
+ "the",
9882
+ "a",
9883
+ "an",
9884
+ "my",
9885
+ "your",
9886
+ "our",
9887
+ "their",
9888
+ "is",
9889
+ "are",
9890
+ "um",
9891
+ "uh",
9892
+ // Hebrew connectives/prepositions a request cannot end on ("תפתח את", "ואז")
9893
+ "\u05E9\u05DC",
9894
+ "\u05D0\u05EA",
9895
+ "\u05E2\u05DD",
9896
+ "\u05E2\u05DC",
9897
+ "\u05D0\u05D1\u05DC",
9898
+ "\u05D0\u05D5",
9899
+ "\u05DB\u05D9",
9900
+ "\u05D0\u05DD",
9901
+ "\u05D5\u05D0\u05D6",
9902
+ "\u05D5\u05D2\u05DD",
9903
+ "\u05DB\u05DE\u05D5",
9904
+ "\u05DC\u05D2\u05D1\u05D9",
9905
+ "\u05D1\u05E9\u05D1\u05D9\u05DC",
9906
+ "\u05D0\u05DC",
9907
+ "\u05E2\u05D3",
9908
+ "\u05D0\u05D7\u05E8\u05D9",
9909
+ "\u05DC\u05E4\u05E0\u05D9",
9910
+ "\u05E9\u05D6\u05D4",
9911
+ "\u05D0\u05D4",
9912
+ "\u05D0\u05DE\u05DE"
9913
+ ]);
9914
+ var TRAILING_PREPS = /* @__PURE__ */ new Set(["to", "for", "with", "about", "of", "at", "in", "on", "from", "by", "into", "under", "inside"]);
9915
+ var QUESTION_START = /^(?:what|who|whom|whose|where|when|why|how|which|is|are|was|were|do|does|did|can|could|will|would|should|have|has)\b/i;
9916
+ var PHRASAL_HOST = /* @__PURE__ */ new Set(["it", "this", "that", "them", "him", "her", "me", "us", "you", "up", "out", "off", "down", "back", "over", "away"]);
9917
+ function endsMidPhrase(text) {
9918
+ const t = text.split("\n[")[0].trim();
9919
+ if (!t || /[.?!…]["')\]]*$/.test(t)) return false;
9920
+ if (/\b[a-z][\w+.-]*:\/\/\S+$/i.test(t)) return false;
9921
+ if (/[,/\\:\-–—]$/.test(t)) return true;
9922
+ const [last, prev] = (t.toLowerCase().match(/[\p{L}']+/gu) ?? []).reverse();
9923
+ if (!last) return false;
9924
+ if (TRAILING_PREPS.has(last)) return !QUESTION_START.test(t) && !PHRASAL_HOST.has(prev);
9925
+ return MID_PHRASE_WORDS.has(last);
9926
+ }
9927
+ var words = (s) => s.trim() ? s.trim().split(/\s+/) : [];
9928
+ function isClosing(text) {
9929
+ return words(text).length <= 8 && /(?:^|[^\p{L}])(?:good ?bye|bye|see you|good ?night|talk (?:to you )?later|hang up|end (?:the )?call|ביי|להתראות|נתראה|לילה טוב|יאללה ביי)(?:$|[^\p{L}])/iu.test(text);
9930
+ }
9931
+ var TurnGateOptions = class {
9932
+ holdMs = 1200;
9933
+ /** An identical final within this window is a duplicate. */
9934
+ dupWindowMs = 5e3;
9935
+ /** A fuller final within this window of the last turn (or with its sttId) is a re-final of it. */
9936
+ refinalWindowMs = 5e3;
9937
+ onTurn = () => {
9938
+ };
9939
+ onDrop = () => {
9940
+ };
9941
+ /** A final is being held (ends mid-phrase) — the fill channel may cover it with a filler, never a bridge. */
9942
+ onHold = () => {
9943
+ };
9944
+ now = () => performance.now();
9945
+ };
9946
+ var TurnGate = class {
9947
+ constructor(options) {
9948
+ this.options = options;
9949
+ this.options = { ...new TurnGateOptions(), ...options };
9950
+ }
9951
+ options;
9952
+ last;
9953
+ held;
9954
+ seq = 0;
9955
+ closed = false;
9956
+ get o() {
9957
+ return this.options;
9958
+ }
9959
+ /** A closing turn went out — the call is ending. */
9960
+ get ending() {
9961
+ return this.closed;
9962
+ }
9963
+ /** Is a final held right now? */
9964
+ get holding() {
9965
+ return !!this.held;
9966
+ }
9967
+ /** The last turn sent (id + text), if any. */
9968
+ get lastTurn() {
9969
+ return this.last && { id: this.last.id, text: this.last.text, started: this.last.started };
9970
+ }
9971
+ /** An STT final. */
9972
+ final(text, sttId) {
9973
+ let t = text.trim();
9974
+ if (!t) return this.o.onDrop(text, "empty");
9975
+ if (this.held) {
9976
+ const h = this.held;
9977
+ clearTimeout(h.timer);
9978
+ this.held = void 0;
9979
+ if (!(norm3(t) + " ").startsWith(norm3(h.text) + " ")) t = `${h.text} ${t}`;
9980
+ sttId ??= h.sttId;
9981
+ }
9982
+ const key = norm3(t), now3 = this.o.now(), last = this.last;
9983
+ if (last) {
9984
+ const age = now3 - last.at;
9985
+ if (key === last.key && age < this.o.dupWindowMs) return this.o.onDrop(t, "dupe");
9986
+ const sameStt = !!sttId && sttId === last.sttId;
9987
+ if ((sameStt || age < this.o.refinalWindowMs) && key && (last.key + " ").startsWith(key + " ")) return this.o.onDrop(t, "stale");
9988
+ if (sameStt || age < this.o.refinalWindowMs && key.startsWith(last.key + " ")) return this.refinal(last, t, key, sttId);
9989
+ }
9990
+ if (this.closed && isClosing(t)) return this.o.onDrop(t, "goodbye");
9991
+ if (endsMidPhrase(t)) {
9992
+ this.held = { text: t, sttId, timer: setTimeout(() => this.flush(), this.o.holdMs) };
9993
+ return this.o.onHold(t);
9994
+ }
9995
+ this.send(t, key, sttId, "new");
9996
+ }
9997
+ /** The user is speaking again (a partial): a held fragment waits for the rest. */
9998
+ speaking() {
9999
+ if (!this.held) return;
10000
+ clearTimeout(this.held.timer);
10001
+ this.held.timer = setTimeout(() => this.flush(), this.o.holdMs);
10002
+ }
10003
+ /** MAIN started replying to turn `id` (its first text/tool/audio): a later re-final only adds its new words. */
10004
+ replyStarted(id) {
10005
+ if (this.last?.id === id) this.last.started = true;
10006
+ }
10007
+ /** Send a held fragment now (e.g. the call ends). */
10008
+ flush() {
10009
+ const h = this.held;
10010
+ if (!h) return;
10011
+ clearTimeout(h.timer);
10012
+ this.held = void 0;
10013
+ if (this.closed && isClosing(h.text)) return this.o.onDrop(h.text, "goodbye");
10014
+ this.send(h.text, norm3(h.text), h.sttId, "new");
10015
+ }
10016
+ end() {
10017
+ if (this.held) {
10018
+ clearTimeout(this.held.timer);
10019
+ this.held = void 0;
10020
+ }
10021
+ }
10022
+ refinal(last, t, key, sttId) {
10023
+ if (!last.started) return this.send(t, key, sttId ?? last.sttId, "replace", last.id);
10024
+ const a = words(last.text), b = words(t);
10025
+ let i = 0;
10026
+ while (i < a.length && i < b.length && norm3(a[i]) === norm3(b[i])) i++;
10027
+ const extra = b.slice(i).join(" ");
10028
+ if (!norm3(extra)) return this.o.onDrop(t, "stale");
10029
+ if (this.closed && isClosing(extra)) return this.o.onDrop(t, "goodbye");
10030
+ this.last = { ...last, text: t, key, at: this.o.now() };
10031
+ this.emit(extra, "extend");
10032
+ }
10033
+ send(t, key, sttId, kind, replaces) {
10034
+ const id = this.emit(t, kind, replaces);
10035
+ this.last = { id, text: t, key, sttId, at: this.o.now(), started: false };
10036
+ }
10037
+ emit(text, kind, replaces) {
10038
+ const id = `u${++this.seq}`;
10039
+ if (isClosing(text)) this.closed = true;
10040
+ if (this.last && kind === "extend") this.last.id = id;
10041
+ this.o.onTurn({ id, text, kind, ...replaces ? { replaces } : {} });
10042
+ return id;
10043
+ }
10044
+ };
9414
10045
 
9415
10046
  // src/duplex.ts
9416
- var log23 = forComponent("DuplexAgent");
10047
+ var log24 = forComponent("DuplexAgent");
9417
10048
  function describeCall(call) {
9418
10049
  const v = call.args && Object.values(call.args).find((x) => typeof x === "string" && x.trim());
9419
10050
  const hint = v ? ` (${String(v).replace(/\s+/g, " ").trim().slice(0, 48)})` : "";
@@ -9426,12 +10057,19 @@ function refusedCall(e) {
9426
10057
  function rateLimited(e) {
9427
10058
  return Number(e?.statusCode ?? e?.status) === 429 || /rate.?limit|RATE_LIMIT|tokens per minute|too many requests/i.test(String(e?.message ?? e?.code ?? e ?? ""));
9428
10059
  }
9429
- var LEAD_TAGS = String.raw`^(?:\s*\[[^\]]*\]\s*)*\s*`;
9430
- var RESULT_CLAIM_RE = new RegExp(LEAD_TAGS + String.raw`(?:(?:all\s+)?done|finished|(?:i\s+)?found|i(?:['’]ve|\s+have)?\s+(?:found|checked|looked|scanned|searched)|there\s+(?:are|is|were|was)\s+no|no\s+(?:files?|results?|matches|sessions?))\b`, "i");
9431
- var DONE_CLAIM_RE = new RegExp(LEAD_TAGS + String.raw`(?:(?:all\s+)?done|finished|it['’]?s\s+(?:done|finished)|completed?)\b`, "i");
10060
+ var bare = (s) => s.replace(/\[[^\]]*\]/g, " ").replace(/[\s\u00a0]+/g, " ").trim().toLowerCase();
10061
+ function restarts(stub, next) {
10062
+ const w = bare(stub).match(/^[\p{L}\p{N}'’]+/u)?.[0];
10063
+ return !!w && bare(next).startsWith(w);
10064
+ }
10065
+ function isFalseStart(sentence) {
10066
+ const s = sentence.replace(/\[[^\]]*\]/g, " ").replace(/[\s\u00a0]+/g, " ").trim();
10067
+ const m = s.match(/^([\p{L}\p{N}'’]+(?: [\p{L}\p{N}'’,]+)?)\s*(?:…|\.{2,})[\s.…]*$/u);
10068
+ return !!m && !/^(?:h+m+|u+m+|u+h+|o+h+|a+h+|well|so|er+m*)$/i.test(m[1]);
10069
+ }
9432
10070
  function wordOverlap(sentence, prior) {
9433
- const words = (x) => x.replace(/\[[^\]]*\]/g, " ").toLowerCase().replace(/['’]/g, "").match(new RegExp("\\p{L}{4,}", "gu")) ?? [];
9434
- const have = new Set(words(prior)), ws = words(sentence);
10071
+ const words3 = (x) => x.replace(/\[[^\]]*\]/g, " ").toLowerCase().replace(/['’]/g, "").match(new RegExp("\\p{L}{4,}", "gu")) ?? [];
10072
+ const have = new Set(words3(prior)), ws = words3(sentence);
9435
10073
  return ws.length ? ws.filter((w) => have.has(w)).length / ws.length : 0;
9436
10074
  }
9437
10075
  var DuplexAgentOptions = class {
@@ -9525,49 +10163,6 @@ var DuplexAgentOptions = class {
9525
10163
  memoryUserDir;
9526
10164
  };
9527
10165
  var SESSION_ID_RE = /^\d{8}-\d{6}(?:-[\w-]+)?$/;
9528
- var MID_PHRASE_WORDS = /* @__PURE__ */ new Set([
9529
- "slash",
9530
- "backslash",
9531
- "dash",
9532
- "hyphen",
9533
- "dot",
9534
- "underscore",
9535
- "colon",
9536
- "comma",
9537
- "and",
9538
- "or",
9539
- "but",
9540
- "so",
9541
- "because",
9542
- "if",
9543
- "then",
9544
- "called",
9545
- "named",
9546
- "the",
9547
- "a",
9548
- "an",
9549
- "my",
9550
- "your",
9551
- "our",
9552
- "their",
9553
- "is",
9554
- "are",
9555
- "um",
9556
- "uh"
9557
- ]);
9558
- var TRAILING_PREPS = /* @__PURE__ */ new Set(["to", "for", "with", "about", "of", "at", "in", "on", "from", "by", "into", "under", "inside"]);
9559
- var QUESTION_START = /^(?:what|who|whom|whose|where|when|why|how|which|is|are|was|were|do|does|did|can|could|will|would|should|have|has)\b/i;
9560
- var PHRASAL_HOST = /* @__PURE__ */ new Set(["it", "this", "that", "them", "him", "her", "me", "us", "you", "up", "out", "off", "down", "back", "over", "away"]);
9561
- function endsMidPhrase(text) {
9562
- const t = text.split("\n[")[0].trim();
9563
- if (!t || /[.?!…]["')\]]*$/.test(t)) return false;
9564
- if (/\b[a-z][\w+.-]*:\/\/\S+$/i.test(t)) return false;
9565
- if (/[,/\\:\-–—]$/.test(t)) return true;
9566
- const [last, prev] = (t.toLowerCase().match(/[\p{L}']+/gu) ?? []).reverse();
9567
- if (!last) return false;
9568
- if (TRAILING_PREPS.has(last)) return !QUESTION_START.test(t) && !PHRASAL_HOST.has(prev);
9569
- return MID_PHRASE_WORDS.has(last);
9570
- }
9571
10166
  function nameScore(want, have) {
9572
10167
  const a = want.toLowerCase().replace(/[^\p{L}\p{N}]/gu, ""), b = have.toLowerCase().replace(/[^\p{L}\p{N}]/gu, "");
9573
10168
  if (!a || !b) return 0;
@@ -9601,7 +10196,7 @@ function isOfferAcceptance(text) {
9601
10196
  }
9602
10197
  var isQuestion = (s) => /\?["'”’)\]]*$/.test(s.trim());
9603
10198
  var OFFER_OVERRUN = 150;
9604
- 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. A short yes or agreement right after you asked or offered something answers THAT question: act on it now (dispatch the work it needs) \u2014 never re-ask it, rephrase it, or narrow it into a new question. When the user says you looked in the wrong place, or doubts a "not found", do not defend the search you did: dispatch `Act` with their hint (the place, the id, the tool they name). Only say you checked or searched something you actually checked with a tool this conversation \u2014 name exactly what you looked at.\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 it ENDS mid-sentence or on a hesitation ("and then we", "but the problem is", "so I was, um") \u2014 call `Hold` instead of answering. A hesitation INSIDE a finished message ("can you, um, open it?") does not make it incomplete, and an unclear or odd but finished message is not a fragment: answer it, or ask one short question \u2014 never Hold on it. 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 \u2014 to stop everything ("stop all", "cancel everything") pass id "all". When the user restates, continues, or rephrases a question a still-running task is already answering, it is the SAME question \u2014 do not answer it yourself (the task\'s answer is coming; two answers confuse): say briefly that you are on it. When the user only ADDS to or CORRECTS a request whose task is still running (a detail, a path, a name, a spelling), call `Act` with `refines` set to that task id \u2014 it updates the running worker in place; never cancel and re-dispatch for a refinement.\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 (audio transcribed to text) and can carry transcription errors \u2014 odd words, cut-offs, homophones ("for you" vs "folder"), and especially MISHEARD NAMES: a surname, company, or project name often comes out as ordinary sound-alike words. Read for INTENT, not surface text, and do not silently rewrite what they said. If a message seems garbled, surprising, or only half-parses, or a word sounds like a name you already know (from memory or this conversation) but is spelled differently, treat it with suspicion \u2014 do NOT act on the odd word blindly or improvise content from it; briefly confirm what they meant ("did you mean\u2026?") and wait. A one-line confirm beats a confident wrong answer. NEVER save to memory a name, spelling, or term you only HEARD (it may be a transcription error) \u2014 when the user spells something aloud, read the spelling back and get a yes before storing it.';
10199
+ 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. A short yes or agreement right after you asked or offered something answers THAT question: act on it now (dispatch the work it needs) \u2014 never re-ask it, rephrase it, or narrow it into a new question. When the user says you looked in the wrong place, or doubts a "not found", do not defend the search you did: dispatch `Act` with their hint (the place, the id, the tool they name). Only say you checked or searched something you actually checked with a tool this conversation \u2014 name exactly what you looked at.\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 it ENDS mid-sentence or on a hesitation ("and then we", "but the problem is", "so I was, um") \u2014 call `Hold` instead of answering. A hesitation INSIDE a finished message ("can you, um, open it?") does not make it incomplete, and an unclear or odd but finished message is not a fragment: answer it, or ask one short question \u2014 never Hold on it. 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 \u2014 to stop everything ("stop all", "cancel everything") pass id "all". When the user restates, continues, or rephrases a question a still-running task is already answering, it is the SAME question \u2014 do not answer it yourself (the task\'s answer is coming; two answers confuse): say briefly that you are on it. When the user only ADDS to or CORRECTS a request whose task is still running (a detail, a path, a name, a spelling), call `Act` with `refines` set to that task id \u2014 it updates the running worker in place; never cancel and re-dispatch for a refinement.\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 (audio transcribed to text) and can carry transcription errors \u2014 odd words, cut-offs, homophones ("for you" vs "folder"), and especially MISHEARD NAMES: a surname, company, or project name often comes out as ordinary sound-alike words. Read for INTENT, not surface text, and do not silently rewrite what they said. If a message seems garbled, surprising, or only half-parses, or a word sounds like a name you already know (from memory or this conversation) but is spelled differently, treat it with suspicion \u2014 do NOT act on the odd word blindly or improvise content from it; briefly confirm what they meant ("did you mean\u2026?") and wait. A one-line confirm beats a confident wrong answer. SPELLED-OUT WORDS: when the user spells something letter by letter (single letters joined by dashes or spaces, "dot" for "."), the spelling is EXACT and OVERRIDES the word you heard: assemble exactly those letters and use that string \u2014 never keep the sound-alike word, never blend the two, never add letters they did not say. A message noting `[spelled out letter by letter: "\u2026"]` has done the assembly for you. If you already used the heard word, say the corrected one back once.\nNEVER save to memory a name, spelling, or term you only HEARD (it may be a transcription error) \u2014 when the user spells something aloud, read the spelling back and get a yes before storing it.';
9605
10200
  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.";
9606
10201
  var THINK_DISABLED_GUIDANCE = "(Think tier is not available \u2014 use Act for all escalations.)";
9607
10202
  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).`;
@@ -9768,7 +10363,7 @@ ${nowLine()}. Anchor every relative or time-sensitive reference \u2014 "today",
9768
10363
  const m = this.reflexBuf.match(RESERVED_EVENT_MARKER) ?? this.reflexBuf.match(RESERVED_EVENT_OPENER);
9769
10364
  if (m) {
9770
10365
  this.fabricationCut = true;
9771
- log23.warn(`reflex fabricated a [task \u2026] event in its spoken stream \u2014 cutting it (kept ${m.index} chars)`);
10366
+ log24.warn(`reflex fabricated a [task \u2026] event in its spoken stream \u2014 cutting it (kept ${m.index} chars)`);
9772
10367
  this.reflexBuf = this.reflexBuf.slice(0, Math.max(m.index, this.reflexForwarded));
9773
10368
  this.pumpReflex(true);
9774
10369
  return;
@@ -9860,10 +10455,18 @@ ${nowLine()}. Anchor every relative or time-sensitive reference \u2014 "today",
9860
10455
  for (const k of this.stepMarks) if (k > from && k <= upto) ends.push(k - from);
9861
10456
  if (final || !ends.length && region.length >= 160) ends.push(region.length);
9862
10457
  ends.sort((a, b) => a - b);
9863
- let ok = 0, leak, repeat;
10458
+ let ok = 0, leak, repeat, stub;
9864
10459
  for (const e of ends) {
9865
10460
  if (e <= ok) continue;
9866
10461
  const sent = region.slice(ok, e);
10462
+ if (isFalseStart(sent)) {
10463
+ const e2 = ends.find((x) => x > e);
10464
+ if (e2 === void 0 && !final) break;
10465
+ if (e2 !== void 0 && restarts(sent, region.slice(e, e2))) {
10466
+ stub = [ok, e];
10467
+ break;
10468
+ }
10469
+ }
9867
10470
  if (sent.trim() && !/[\p{L}\p{N}]/u.test(sent) || isRepeatOf(sent, this.turnSpoken + region.slice(0, ok))) {
9868
10471
  repeat = e;
9869
10472
  break;
@@ -9878,6 +10481,16 @@ ${nowLine()}. Anchor every relative or time-sensitive reference \u2014 "today",
9878
10481
  ok = e;
9879
10482
  }
9880
10483
  this.forwardReflex(from + ok);
10484
+ if (stub) {
10485
+ const [a, b] = [from + stub[0], from + stub[1]];
10486
+ this.repeatCuts.push(region.slice(stub[0], stub[1]).trim());
10487
+ this.notify("diag", "reflex_stub", { text: region.slice(stub[0], stub[1]).trim().slice(0, 40) });
10488
+ this.reflexBuf = this.reflexBuf.slice(0, a) + this.reflexBuf.slice(b);
10489
+ this.stepMarks = this.stepMarks.map((k) => k >= b ? k - (b - a) : k > a ? a : k);
10490
+ if (this.dispatchAt >= b) this.dispatchAt -= b - a;
10491
+ else if (this.dispatchAt > a) this.dispatchAt = a;
10492
+ continue;
10493
+ }
9881
10494
  if (repeat !== void 0) {
9882
10495
  const cut = region.slice(ok, repeat).trim();
9883
10496
  if (/[\p{L}\p{N}]/u.test(cut)) {
@@ -9892,7 +10505,7 @@ ${nowLine()}. Anchor every relative or time-sensitive reference \u2014 "today",
9892
10505
  this.leakHit = true;
9893
10506
  this.leakCuts.push(leak.trim().slice(0, 40));
9894
10507
  const why = this.cutKind === "claim" ? "stated a result no task has given yet" : this.cutKind === "restart" ? "started a second, restated reply" : "spoke its own analysis";
9895
- log23.warn(`reflex ${why} ("${leak.trim().slice(0, 40)}\u2026") \u2014 cut before TTS`);
10508
+ log24.warn(`reflex ${why} ("${leak.trim().slice(0, 40)}\u2026") \u2014 cut before TTS`);
9896
10509
  this.notify("diag", this.cutKind === "claim" ? "result_claim" : this.cutKind === "restart" ? "reply_restart" : "analysis_leak", { text: leak.trim().slice(0, 80), at: this.turnSpoken.trim() ? "mid_reply" : "before_speech" });
9897
10510
  const next = this.stepMarks.find((k) => k > at);
9898
10511
  if (next === void 0) {
@@ -9933,7 +10546,7 @@ ${nowLine()}. Anchor every relative or time-sensitive reference \u2014 "today",
9933
10546
  this.notify("diag", "leak_judge", { text: s.slice(0, 80), p: Math.round(p * 100) / 100, ms: Math.round(performance.now() - t0) });
9934
10547
  return p >= LEAK_JUDGE_THRESHOLD;
9935
10548
  }, (e) => {
9936
- log23.warn(`leak judge failed \u2014 speaking the sentence: ${e.message}`);
10549
+ log24.warn(`leak judge failed \u2014 speaking the sentence: ${e.message}`);
9937
10550
  return false;
9938
10551
  }).then((leak) => {
9939
10552
  clearTimeout(timer);
@@ -10005,7 +10618,7 @@ ${nowLine()}. Anchor every relative or time-sensitive reference \u2014 "today",
10005
10618
  let i = t.length - 1;
10006
10619
  while (i >= this.turnBase && !(t[i].role === "assistant" && typeof t[i].content === "string" && t[i].content.includes(key))) i--;
10007
10620
  if (i < this.turnBase) {
10008
- log23.warn("analysis leak cut, but its text was not found in the transcript to scrub");
10621
+ log24.warn("analysis leak cut, but its text was not found in the transcript to scrub");
10009
10622
  continue;
10010
10623
  }
10011
10624
  const m = t[i];
@@ -10019,10 +10632,12 @@ ${nowLine()}. Anchor every relative or time-sensitive reference \u2014 "today",
10019
10632
  * turn. Strips the text of assistant messages after the Hold call (tool calls stay — they happened); a message left
10020
10633
  * empty is removed. */
10021
10634
  scrubHeldText() {
10022
- if (!this.heldThisTurn) return;
10635
+ const ackedDispatch = !this.heldThisTurn && this.turnDispatched && this.spokeBeforeDispatch;
10636
+ if (!this.heldThisTurn && !ackedDispatch) return;
10637
+ const anchor = (n) => ackedDispatch ? n === "Act" || n === "Think" : n === "Hold";
10023
10638
  const t = this.voice.transcript;
10024
10639
  let i = this.turnBase;
10025
- while (i < t.length && !t[i].tool_calls?.some((c) => c.function.name === "Hold")) i++;
10640
+ while (i < t.length && !t[i].tool_calls?.some((c) => anchor(c.function.name))) i++;
10026
10641
  for (let j = t.length - 1; j > i; j--) {
10027
10642
  const m = t[j];
10028
10643
  if (m.role !== "assistant" || !contentText(m.content).trim()) continue;
@@ -10060,7 +10675,7 @@ ${nowLine()}. Anchor every relative or time-sensitive reference \u2014 "today",
10060
10675
  const res = await this.voice.send(content);
10061
10676
  const fb = this.options.reflexFallbackModel, model = this.voice.options.model;
10062
10677
  if (res.finishReason !== "error" || !rateLimited(res.error) || !fb || fb === model || fb.includes("/") && model.includes("/") && fb.split("/")[0] !== model.split("/")[0] || this.turnDispatched || this.turnSpoken.trim()) return res;
10063
- log23.warn(`reflex ${model} rate-limited \u2014 re-running the turn on ${fb}`);
10678
+ log24.warn(`reflex ${model} rate-limited \u2014 re-running the turn on ${fb}`);
10064
10679
  this.notify("diag", "reflex_fallback", { from: model, to: fb });
10065
10680
  this.voice.transcript.length = base;
10066
10681
  this.voice.options.model = fb;
@@ -10093,7 +10708,7 @@ ${nowLine()}. Anchor every relative or time-sensitive reference \u2014 "today",
10093
10708
  try {
10094
10709
  await this.voice.send(reminder);
10095
10710
  } catch (e) {
10096
- log23.warn(`follow-through nudge failed: ${e instanceof Error ? e.message : e}`);
10711
+ log24.warn(`follow-through nudge failed: ${e instanceof Error ? e.message : e}`);
10097
10712
  } finally {
10098
10713
  const i = this.voice.transcript.findIndex((m, k) => k >= base && m.role === "user" && m.content === reminder);
10099
10714
  if (i >= 0) this.voice.transcript.splice(i, 1);
@@ -10154,7 +10769,7 @@ ${nowLine()}. Anchor every relative or time-sensitive reference \u2014 "today",
10154
10769
  return {
10155
10770
  preToolUse: (call) => {
10156
10771
  if (this.spec?.state === "pending" && !_DuplexAgent.SPEC_SAFE_TOOLS.has(call.name)) {
10157
- log23.verbose(`speculation aborted: reflex called ${call.name} on unconfirmed input`);
10772
+ log24.verbose(`speculation aborted: reflex called ${call.name} on unconfirmed input`);
10158
10773
  this.abortSpeculation();
10159
10774
  return { block: true, reason: "Speculative turn aborted." };
10160
10775
  }
@@ -10236,7 +10851,7 @@ ${nowLine()}. Anchor every relative or time-sensitive reference \u2014 "today",
10236
10851
  try {
10237
10852
  await this.voice.send(reminder);
10238
10853
  } catch (e) {
10239
- log23.warn(`ack nudge failed: ${e instanceof Error ? e.message : e}`);
10854
+ log24.warn(`ack nudge failed: ${e instanceof Error ? e.message : e}`);
10240
10855
  } finally {
10241
10856
  this.nudging = false;
10242
10857
  const i = this.voice.transcript.findIndex((m, k) => k >= base && m.role === "user" && m.content === reminder);
@@ -10291,7 +10906,7 @@ ${nowLine()}. Anchor every relative or time-sensitive reference \u2014 "today",
10291
10906
  const accepted = this.offerOpen && this.parkedRedeliver.length > 0 && this.parkedRedeliver.every((p) => p.task === this.offer.task) && (isOfferAcceptance(content) || isTrivialBarge(content));
10292
10907
  const judge = accepted ? void 0 : this.options.bargeIntent;
10293
10908
  return (accepted ? Promise.resolve("resume") : judge ? judge(content).catch((e) => {
10294
- log23.warn(`bargeIntent judge failed \u2014 using the allowlist: ${e.message}`);
10909
+ log24.warn(`bargeIntent judge failed \u2014 using the allowlist: ${e.message}`);
10295
10910
  return fallback();
10296
10911
  }) : Promise.resolve(fallback())).then((intent) => {
10297
10912
  this.notify("diag", "barge_intent", { intent, text: content.slice(0, 80) });
@@ -10434,6 +11049,7 @@ ${nowLine()}. Anchor every relative or time-sensitive reference \u2014 "today",
10434
11049
  this.voice.options.signal = signal;
10435
11050
  this.turnSignal = { own: signal, ext };
10436
11051
  }
11052
+ if (typeof content === "string") content += spellingNote(content);
10437
11053
  let res;
10438
11054
  try {
10439
11055
  res = await this.reflexSend(content);
@@ -10487,7 +11103,7 @@ ${nowLine()}. Anchor every relative or time-sensitive reference \u2014 "today",
10487
11103
  try {
10488
11104
  res = await this.voice.send(spec.text);
10489
11105
  } catch (e) {
10490
- log23.warn(`speculative turn failed: ${e instanceof Error ? e.message : e}`);
11106
+ log24.warn(`speculative turn failed: ${e instanceof Error ? e.message : e}`);
10491
11107
  } finally {
10492
11108
  this.voice.options.signal = prevSignal;
10493
11109
  }
@@ -10501,7 +11117,7 @@ ${nowLine()}. Anchor every relative or time-sensitive reference \u2014 "today",
10501
11117
  if (d === "abort") {
10502
11118
  if (this.voice.transcript.length > base) this.voice.transcript.length = base;
10503
11119
  this.speculativeAbortedCalls++;
10504
- log23.verbose(`speculation aborted (${this.speculativeAbortedCalls} total): "${spec.text.slice(0, 50)}"`);
11120
+ log24.verbose(`speculation aborted (${this.speculativeAbortedCalls} total): "${spec.text.slice(0, 50)}"`);
10505
11121
  if (this.spec === spec) this.spec = void 0;
10506
11122
  return res ?? empty;
10507
11123
  }
@@ -10765,7 +11381,7 @@ Update: ${brief}`;
10765
11381
  }
10766
11382
  buildBrief(brief, tier = "act", deliver = true) {
10767
11383
  const recent = this.recentExcerpt();
10768
- 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." : "";
11384
+ 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. A command exiting 0 or echoing its input means it was ACCEPTED, not that it worked: before you say a web address was opened or reached, confirm it exists (its host resolves in DNS and answers an HTTP request); if it does not, report that it does not exist instead of success, and name the address you tried." : "";
10769
11385
  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. Never read a file path, URL, command or code aloud, nor spell one out ("slash \u2026 slash \u2026") \u2014 say it the way a person would, by the name of the folder, project, file or site; the exact text stays in your written prose. ' + (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." : "") : "";
10770
11386
  return `${nowLine()}.
10771
11387
 
@@ -10884,7 +11500,7 @@ Another agent just implemented the above. Independently check the CURRENT state
10884
11500
  this.notify("task_verify", `task ${id}: verifying`, { id });
10885
11501
  const cres = await new Agent(checkerOpts).run(checkBrief);
10886
11502
  if (cres.finishReason !== "stop") {
10887
- log23.warn(`task ${id}: verify inconclusive (${cres.finishReason})`);
11503
+ log24.warn(`task ${id}: verify inconclusive (${cres.finishReason})`);
10888
11504
  this.notify("task_verify", `task ${id}: verify inconclusive (${cres.finishReason})`, { id, finishReason: cres.finishReason });
10889
11505
  }
10890
11506
  const sum = (a = 0, b = 0) => a + b;
@@ -11020,7 +11636,7 @@ Another agent just implemented the above. Independently check the CURRENT state
11020
11636
  rec.status = "done";
11021
11637
  rec.result = res.text;
11022
11638
  const incomplete = res.finishReason !== "stop" && res.finishReason !== "empty";
11023
- log23.verbose(`task ${id} done (${res.steps} steps${incomplete ? `, INCOMPLETE: ${res.finishReason}` : ""})`);
11639
+ log24.verbose(`task ${id} done (${res.steps} steps${incomplete ? `, INCOMPLETE: ${res.finishReason}` : ""})`);
11024
11640
  this.notify("task_done", `task ${id} (${rec.label}) completed`, {
11025
11641
  id,
11026
11642
  text: res.text,
@@ -11054,14 +11670,14 @@ Another agent just implemented the above. Independently check the CURRENT state
11054
11670
  foldIfSuperseded(rec, note) {
11055
11671
  if (rec.deliveryParked !== "moved_on") return false;
11056
11672
  if (note.trim()) this.voice.transcript.push({ role: "assistant", content: note });
11057
- log23.verbose(`task ${rec.id} settled but superseded (${rec.status}) \u2014 folded into transcript, not voiced`);
11673
+ log24.verbose(`task ${rec.id} settled but superseded (${rec.status}) \u2014 folded into transcript, not voiced`);
11058
11674
  return true;
11059
11675
  }
11060
11676
  failTask(rec, msg) {
11061
11677
  this.dropAsk(rec.id);
11062
11678
  rec.status = "error";
11063
11679
  rec.result = msg;
11064
- log23.warn(`task ${rec.id} failed: ${msg}`);
11680
+ log24.warn(`task ${rec.id} failed: ${msg}`);
11065
11681
  this.notify("task_error", `task ${rec.id} (${rec.label}) failed: ${msg}`, { id: rec.id });
11066
11682
  if (this.foldIfSuperseded(rec, `The "${rec.label}" task failed: ${msg}`)) return;
11067
11683
  this.dropParked(rec);
@@ -11365,7 +11981,7 @@ Another agent just implemented the above. Independently check the CURRENT state
11365
11981
  init_llm();
11366
11982
  init_logging();
11367
11983
  init_models();
11368
- var log24 = forComponent("ModelRouter");
11984
+ var log25 = forComponent("ModelRouter");
11369
11985
  var ModelRouterOptions = class {
11370
11986
  classifier = jevClassifier();
11371
11987
  /** Capability tiers → model id (any provider). basic/standard run the main session; premium is only ever an offload. */
@@ -11449,10 +12065,10 @@ var ModelRouter = class {
11449
12065
  if (tier === "premium") d.offload = this.state.offload && (c.sameThread ?? 0) >= o.sameThreadAt ? "continue" : "fresh";
11450
12066
  } catch (e) {
11451
12067
  const msg = e instanceof Error ? e.message : String(e);
11452
- log24.warn(`classifier failed, falling back to standard: ${msg}`);
12068
+ log25.warn(`classifier failed, falling back to standard: ${msg}`);
11453
12069
  d = { tier: "standard", model: o.models.standard, probs: {}, fallback: msg, classifyMs: Math.round(performance.now() - t0) };
11454
12070
  }
11455
- log24.info(`route ${d.tier}${d.offload ? `(${d.offload})` : ""} \xB7 argmax=${d.choice ?? "-"} \xB7 p=${JSON.stringify(d.probs)}${d.sameThread != null ? ` \xB7 same=${d.sameThread.toFixed(2)}` : ""} \xB7 ${d.classifyMs}ms`);
12071
+ log25.info(`route ${d.tier}${d.offload ? `(${d.offload})` : ""} \xB7 argmax=${d.choice ?? "-"} \xB7 p=${JSON.stringify(d.probs)}${d.sameThread != null ? ` \xB7 same=${d.sameThread.toFixed(2)}` : ""} \xB7 ${d.classifyMs}ms`);
11456
12072
  return d;
11457
12073
  }
11458
12074
  /** Run one user turn on `agent` with routing. Returns a RunResult carrying `costUsd` + `routing`. */
@@ -11504,7 +12120,7 @@ ${text}`;
11504
12120
  const answer = res.text || `(premium sub-agent finished with no reply; finishReason=${res.finishReason})`;
11505
12121
  agent.transcript.push({ role: "user", content }, { role: "assistant", content: answer });
11506
12122
  this.state.offload = { messages: child.transcript, mainLen: agent.transcript.length, question: text };
11507
- log24.info(`offload ${cont ? "continue" : "fresh"} done \xB7 ${res.finishReason} \xB7 ${res.steps} steps`);
12123
+ log25.info(`offload ${cont ? "continue" : "fresh"} done \xB7 ${res.finishReason} \xB7 ${res.steps} steps`);
11508
12124
  const costUsd = o.costOf(main, briefUsage) + o.costOf(o.models.premium, res.usage);
11509
12125
  return { ...res, text: answer, messages: agent.transcript, usage: addUsage(briefUsage, res.usage), costUsd, routing: d };
11510
12126
  }
@@ -11520,7 +12136,7 @@ The request:
11520
12136
  ${text}`);
11521
12137
  return { text: r.text.trim() || text, usage: r.usage };
11522
12138
  } catch (e) {
11523
- log24.warn(`brief failed, sending the raw request: ${e instanceof Error ? e.message : String(e)}`);
12139
+ log25.warn(`brief failed, sending the raw request: ${e instanceof Error ? e.message : String(e)}`);
11524
12140
  return { text };
11525
12141
  } finally {
11526
12142
  agent.transcript = snap;
@@ -11565,7 +12181,7 @@ async function jevAvailable(opts = {}) {
11565
12181
  const { env } = await loadJev(opts);
11566
12182
  return JEV_KEYS.some((k) => env[k]?.trim());
11567
12183
  } catch (e) {
11568
- log24.debug(`jev unavailable: ${e instanceof Error ? e.message : String(e)}`);
12184
+ log25.debug(`jev unavailable: ${e instanceof Error ? e.message : String(e)}`);
11569
12185
  return false;
11570
12186
  }
11571
12187
  }
@@ -11843,6 +12459,979 @@ var RecordingLifecycle = class {
11843
12459
 
11844
12460
  // src/index.ts
11845
12461
  init_logging();
12462
+
12463
+ // src/voice/call.ts
12464
+ init_logging();
12465
+ init_models();
12466
+
12467
+ // src/voice/progress.ts
12468
+ init_logging();
12469
+ var log26 = forComponent("Progress");
12470
+ var HE_RESULT_CLAIM = /(?:^|\s)(?:מצאתי|סיימתי|בוצע|הנה|אין)(?=\s|$|[.,!…])/u;
12471
+ var TOPICS = [
12472
+ [/kanban|board|card|\bkb\b|list_cards/, "board"],
12473
+ [/mail|gmail|inbox/, "mail"],
12474
+ [/calendar|event|schedule/, "calendar"],
12475
+ [/memory|recall|remember/, "memory"],
12476
+ [/web_?search|websearch|fetch|browse|url|page|tab|browser/, "web"],
12477
+ [/grep|glob|search|find|lookup|quicklook/, "search"],
12478
+ [/edit|write|patch|create|update|save|delete|move/, "write"],
12479
+ [/shell|bash|exec|run|command|terminal/, "run"],
12480
+ [/read|open|view|cat|look|get|list|ls/, "read"]
12481
+ ];
12482
+ var PROGRESS_TEMPLATES = {
12483
+ en: {
12484
+ board: ["checking the board\u2026", "looking at the cards\u2026"],
12485
+ mail: ["checking the mail\u2026", "going through the inbox\u2026"],
12486
+ calendar: ["checking the calendar\u2026", "looking at the schedule\u2026"],
12487
+ memory: ["checking my notes\u2026", "looking back\u2026"],
12488
+ web: ["reading the page\u2026", "looking it up online\u2026"],
12489
+ search: ["searching\u2026", "still looking\u2026"],
12490
+ write: ["making the change\u2026", "writing it down\u2026"],
12491
+ run: ["running it\u2026", "still running\u2026"],
12492
+ read: ["reading it\u2026", "going through it\u2026"]
12493
+ },
12494
+ he: {
12495
+ // plural present: gender-neutral for a voice of either gender
12496
+ board: ["\u05D1\u05D5\u05D3\u05E7\u05D9\u05DD \u05D1\u05DC\u05D5\u05D7\u2026", "\u05DE\u05E1\u05EA\u05DB\u05DC\u05D9\u05DD \u05E2\u05DC \u05D4\u05DB\u05E8\u05D8\u05D9\u05E1\u05D9\u05DD\u2026"],
12497
+ mail: ["\u05D1\u05D5\u05D3\u05E7\u05D9\u05DD \u05D0\u05EA \u05D4\u05DE\u05D9\u05D9\u05DC\u2026", "\u05E2\u05D5\u05D1\u05E8\u05D9\u05DD \u05E2\u05DC \u05D4\u05D3\u05D5\u05D0\u05E8\u2026"],
12498
+ calendar: ["\u05D1\u05D5\u05D3\u05E7\u05D9\u05DD \u05D1\u05D9\u05D5\u05DE\u05DF\u2026", "\u05DE\u05E1\u05EA\u05DB\u05DC\u05D9\u05DD \u05D1\u05DC\u05D5\u05F4\u05D6\u2026"],
12499
+ memory: ["\u05D1\u05D5\u05D3\u05E7\u05D9\u05DD \u05D1\u05E8\u05E9\u05D9\u05DE\u05D5\u05EA\u2026", "\u05DE\u05D7\u05E4\u05E9\u05D9\u05DD \u05D1\u05D6\u05D9\u05DB\u05E8\u05D5\u05DF\u2026"],
12500
+ web: ["\u05E7\u05D5\u05E8\u05D0\u05D9\u05DD \u05D0\u05EA \u05D4\u05D3\u05E3\u2026", "\u05DE\u05D7\u05E4\u05E9\u05D9\u05DD \u05D1\u05E8\u05E9\u05EA\u2026"],
12501
+ search: ["\u05DE\u05D7\u05E4\u05E9\u05D9\u05DD\u2026", "\u05E2\u05D3\u05D9\u05D9\u05DF \u05DE\u05D7\u05E4\u05E9\u05D9\u05DD\u2026"],
12502
+ write: ["\u05DE\u05D1\u05E6\u05E2\u05D9\u05DD \u05D0\u05EA \u05D4\u05E9\u05D9\u05E0\u05D5\u05D9\u2026", "\u05DB\u05D5\u05EA\u05D1\u05D9\u05DD \u05D0\u05EA \u05D6\u05D4\u2026"],
12503
+ run: ["\u05DE\u05E8\u05D9\u05E6\u05D9\u05DD\u2026", "\u05E2\u05D3\u05D9\u05D9\u05DF \u05E8\u05E5\u2026"],
12504
+ read: ["\u05E7\u05D5\u05E8\u05D0\u05D9\u05DD\u2026", "\u05E2\u05D5\u05D1\u05E8\u05D9\u05DD \u05E2\u05DC \u05D6\u05D4\u2026"]
12505
+ }
12506
+ };
12507
+ var ProgressOptions = class {
12508
+ templates = PROGRESS_TEMPLATES;
12509
+ /** Fast-model fallback for a tool with no template (fastProgressGenerator). Absent → silence. */
12510
+ generate;
12511
+ generateBudgetMs = 800;
12512
+ /** Speak only when nothing has been audible for this long. */
12513
+ silenceMs = 3e3;
12514
+ /** At most one progress line per this long. */
12515
+ gapMs = 4e3;
12516
+ now = () => performance.now();
12517
+ };
12518
+ function argsHint(args) {
12519
+ if (!args || typeof args !== "object") return typeof args === "string" ? args.slice(0, 40) : "";
12520
+ const s = Object.values(args).find((v) => typeof v === "string" && v.trim());
12521
+ return typeof s === "string" ? s.trim().replace(/\s+/g, " ").slice(0, 40) : "";
12522
+ }
12523
+ var Progress = class {
12524
+ constructor(options) {
12525
+ this.options = options;
12526
+ this.options = { ...new ProgressOptions(), ...options };
12527
+ }
12528
+ options;
12529
+ said = /* @__PURE__ */ new Set();
12530
+ lastAt = -Infinity;
12531
+ streamed = false;
12532
+ get o() {
12533
+ return this.options;
12534
+ }
12535
+ /** A new MAIN turn started. */
12536
+ turn() {
12537
+ this.streamed = false;
12538
+ }
12539
+ /** MAIN's text streamed this turn: no more progress lines for it. */
12540
+ text() {
12541
+ this.streamed = true;
12542
+ }
12543
+ /** May a line play now? (the caller passes when the user last heard anything) */
12544
+ due(lastAudibleAt) {
12545
+ const now3 = this.o.now();
12546
+ return !this.streamed && now3 - lastAudibleAt >= this.o.silenceMs && now3 - this.lastAt >= this.o.gapMs;
12547
+ }
12548
+ /** Mark `line` spoken. */
12549
+ spoke(line) {
12550
+ this.said.add(norm3(line));
12551
+ this.lastAt = this.o.now();
12552
+ }
12553
+ /** The line for this tool call in `lang`, or '' (no template and no/late/invalid generation, a repeat, a result claim). */
12554
+ async line(tool, args, lang) {
12555
+ if (this.streamed) return "";
12556
+ const name = tool.toLowerCase();
12557
+ const topic = TOPICS.find(([re]) => re.test(name))?.[1];
12558
+ const pool = topic ? this.o.templates[lang]?.[topic] ?? [] : [];
12559
+ const fresh = pool.find((l2) => !this.said.has(norm3(l2)));
12560
+ if (fresh) return fresh;
12561
+ if (!this.o.generate) return "";
12562
+ const ac = new AbortController();
12563
+ let t;
12564
+ const late = new Promise((r) => {
12565
+ t = setTimeout(() => {
12566
+ ac.abort();
12567
+ r(null);
12568
+ }, this.o.generateBudgetMs);
12569
+ });
12570
+ const raw = await Promise.race([this.o.generate(tool, argsHint(args), lang, ac.signal).catch((e) => {
12571
+ log26.warn(`progress line failed: ${e?.message ?? e}`);
12572
+ return null;
12573
+ }), late]).finally(() => clearTimeout(t));
12574
+ const l = cleanLine(raw ?? "", lang, 5);
12575
+ if (!l || RESULT_CLAIM_RE.test(l) || HE_RESULT_CLAIM.test(l) || this.said.has(norm3(l)) || this.streamed) return "";
12576
+ return l;
12577
+ }
12578
+ };
12579
+ function fastProgressGenerator(ai, model) {
12580
+ return async (tool, hint, lang, signal) => {
12581
+ const prompt = `An assistant on a live voice call is running the tool "${tool}"${hint ? ` (about: "${hint}")` : ""}. Write ONE spoken line of 2-5 words, in ${languageName(lang)}, saying what it is doing right now (like "checking the board\u2026"). Never state a result or a finding. Output only the line.`;
12582
+ const reasoning = /gpt-oss/.test(model) ? { providerOptions: { reasoning_effort: "low" } } : {};
12583
+ const r = await ai.chat({ model, messages: [{ role: "user", content: prompt }], stream: false, signal, maxTokens: 150, ...reasoning });
12584
+ return r.content ?? "";
12585
+ };
12586
+ }
12587
+
12588
+ // src/voice/secondChannel.ts
12589
+ init_logging();
12590
+
12591
+ // src/voice/backchannelPlan.ts
12592
+ init_logging();
12593
+ var log27 = forComponent("BackchannelPlan");
12594
+ var silentTurn = (p) => p.density === "none" || p.turn === "short_command" || p.turn === "question";
12595
+ var cueWords = (s) => s.toLowerCase().replace(/[^\p{L}\p{N}'\s]+/gu, " ").split(/\s+/).filter(Boolean);
12596
+ function matchCue(plan, heard, from = 0) {
12597
+ if (!plan || silentTurn(plan)) return null;
12598
+ const w = cueWords(heard);
12599
+ for (let i = Math.max(0, from); i < w.length; i++) {
12600
+ for (const c of plan.cues) {
12601
+ for (const p of c.when) {
12602
+ const pw = p.split(" ");
12603
+ if (i + pw.length <= w.length && pw.every((x, j) => w[i + j] === x)) return c;
12604
+ }
12605
+ }
12606
+ }
12607
+ return null;
12608
+ }
12609
+
12610
+ // src/voice/secondChannel.ts
12611
+ var log28 = forComponent("SecondChannel");
12612
+ var CHANNEL_MODES = {
12613
+ turn: { kinds: ["bridge"], bridgeDelayMs: 1500 },
12614
+ normal: {},
12615
+ chatty: {
12616
+ anticipate: true,
12617
+ cueOnly: false,
12618
+ generateFor: ["backchannel", "bridge"],
12619
+ reactionGapMs: 6e3,
12620
+ generateBudgetMs: 400,
12621
+ // A breath must outlast the word cadence: at ~2.5 words/s interims arrive every ~400ms, so a 250ms "breath" fired after
12622
+ // every word and only the 1.2s gaps held it (~25 lines per 30s, live probe). Lines ≥2.5s apart (light after light 3s), ≤6 in any 30s.
12623
+ backchannelMinWords: 5,
12624
+ backchannelGapMs: 3e3,
12625
+ minGapMs: 2500,
12626
+ backchannelPauseMs: 500,
12627
+ maxPerWindow: 6
12628
+ }
12629
+ };
12630
+ var CHANNEL_MODE_NAMES = Object.keys(CHANNEL_MODES);
12631
+ var SecondChannelOptions = class {
12632
+ /** Preset (CHANNEL_MODES) under the explicit options. */
12633
+ mode = "normal";
12634
+ /** Line kinds this channel may speak. */
12635
+ kinds = ["backchannel", "filler", "bridge"];
12636
+ /** A generated (content) backchannel needs this long since the last one; in between, canned light cues. 0 = always. */
12637
+ reactionGapMs = 0;
12638
+ /** Refiner (Jev/fast LLM): the choice over CHANNEL_ACTIONS, or null on failure. Absent → the rule alone decides.
12639
+ * `state.rule` is the kind the rule proposed (fastChannelDecider answers yes/no on it). `signal` aborts a late call. */
12640
+ decide;
12641
+ /** Fast-model line writer (see fastLineGenerator). Absent or late → the canned bank. */
12642
+ generate;
12643
+ /** Per-language banks (ChannelBanks). Absent → the hardcoded English bank, and silence in any other language. */
12644
+ banks;
12645
+ /** The host's language (BCP-47; pinned — detection never overrides it). Absent → unknown until the user's words
12646
+ * tell it confidently; the channel is silent until then (never English by default). */
12647
+ lang;
12648
+ /** Kinds worth generating (content-conditioned); the rest are canned sounds. */
12649
+ generateFor = ["bridge"];
12650
+ // a backchannel is timing, not content: canned at 0ms (a generated one arrived after the micro-pause)
12651
+ /** Host TTS on the second channel; resolves when the line finished playing. `false` = the host could not play it. */
12652
+ speak = () => {
12653
+ };
12654
+ /** Host UI (caption / muted line) — never history. */
12655
+ show = () => {
12656
+ };
12657
+ /** Stop the in-flight line's audio now (barge-in only). */
12658
+ cancel = () => {
12659
+ };
12660
+ onEvent = () => {
12661
+ };
12662
+ /** Echo cancellation on: backchannels may play while the user speaks. Off (half-duplex) → fillers/bridges in silences only. */
12663
+ aec = false;
12664
+ decideEveryWords = 3;
12665
+ decisionBudgetMs = 300;
12666
+ generateBudgetMs = 500;
12667
+ minGapMs = 2500;
12668
+ /** Backchannel after backchannel needs this gap (~4-6 cues in a 30s monologue — Para phone call 2026-10-13: 5s left
12669
+ * one cue in a long statement). */
12670
+ backchannelGapMs = 3e3;
12671
+ /** Soft cap: at most this many backchannels in any `windowMs` (0 = none). */
12672
+ maxPerWindow = 0;
12673
+ windowMs = 3e4;
12674
+ /** The user resumes within this long of their last words after a line of theirs was dispatched: the same statement goes
12675
+ * on (an STT endpoint mid-monologue) — its words still count toward the backchannel guards, and it is no fresh request. */
12676
+ continueMs = 3e3;
12677
+ /** A decision whose partial grew by this many words while in flight is stale → dropped. */
12678
+ staleWords = 3;
12679
+ microWords = 30;
12680
+ avoidLines = 4;
12681
+ /** Canned picks exclude the last N picks of the same kind. */
12682
+ bankExclude = 2;
12683
+ /** A backchannel needs a substantial chunk first: a turn of ≤8 words is a request/answer, not a story (replay: most
12684
+ * misplaced "mm-hm"s fired at words 6-8 of a short command). */
12685
+ backchannelMinWords = 10;
12686
+ /** A turn that opens as a question/command (requestOpener) holds backchannels until it has grown into an explanation. */
12687
+ requestHoldWords = 18;
12688
+ /** No new interim for this long mid-utterance (a breath / clause gap, shorter than a pause) → a backchannel moment. */
12689
+ backchannelPauseMs = 350;
12690
+ /** A bridge waits this long after the user's turn is dispatched, and plays only if channel-1 has said nothing yet
12691
+ * (a fast reply/ack needs no bridge — one before it reads as the agent starting a different sentence). 0 = at once. */
12692
+ bridgeDelayMs = 1200;
12693
+ /** No new interim for this long → a pause (hosts may also call onPause). */
12694
+ fillerPauseMs = 700;
12695
+ fillerMinWords = 4;
12696
+ bridgeMinWords = 5;
12697
+ /** ANTICIPATE (backchannelPlan.ts): plan the user's next turn off the hot path (after the agent's reply / at turn start,
12698
+ * revised once at `reviseAtWords`); live, a backchannel fires only on a planned cue, at a `cuePauseMs` breath, with the
12699
+ * cue's line and no decider call. A silent plan (short command / question / density none) vetoes backchannels. No plan
12700
+ * yet / failed / late → the reactive path. Off by default (AGENTX_CHANNEL_ANTICIPATE=1). */
12701
+ anticipate = false;
12702
+ plan;
12703
+ planBudgetMs = 4e3;
12704
+ reviseAtWords = 7;
12705
+ /** A predicted moment needs only a short breath (the decision is already made). */
12706
+ cuePauseMs = 200;
12707
+ /** A cue counts only within the last N words (older matches were gated or passed). */
12708
+ cueWindow = 6;
12709
+ /** true: a plan fires on its cues only. false: a non-silent plan with density normal/high also lets the reactive breath
12710
+ * backchannel through (the plan then only vetoes short commands / questions / low density). */
12711
+ cueOnly = true;
12712
+ onPlan = () => {
12713
+ };
12714
+ now = () => performance.now();
12715
+ random = Math.random;
12716
+ };
12717
+ var words2 = (s) => s.trim() ? s.trim().split(/\s+/) : [];
12718
+ function withBudget(p, ms, what, ac) {
12719
+ let t;
12720
+ const late = new Promise((r) => {
12721
+ t = setTimeout(() => {
12722
+ log28.debug(`${what}: over the ${ms}ms budget \u2014 dropped`);
12723
+ ac?.abort();
12724
+ r(null);
12725
+ }, ms);
12726
+ });
12727
+ return Promise.race([p.catch((e) => {
12728
+ const msg = String(e?.message ?? e);
12729
+ if (e?.name === "AbortError" || /aborted/i.test(msg)) log28.debug(`${what}: aborted after its budget`);
12730
+ else log28.warn(`${what} failed \u2014 falling back: ${msg}`);
12731
+ return null;
12732
+ }), late]).finally(() => clearTimeout(t));
12733
+ }
12734
+ var SecondChannel = class {
12735
+ constructor(options) {
12736
+ this.options = options;
12737
+ const preset = CHANNEL_MODES[options?.mode ?? "normal"] ?? {};
12738
+ const base = new SecondChannelOptions();
12739
+ this.options = { ...base, ...preset, ...options };
12740
+ for (const k of Object.keys(preset)) if (!options || !(k in options)) this.normalTiming[k] = base[k];
12741
+ this.startedAt = this.o.now();
12742
+ if (this.o.lang) this.setLanguage(this.o.lang, true);
12743
+ }
12744
+ options;
12745
+ heard = "";
12746
+ userSpeaking = false;
12747
+ main = "idle";
12748
+ gen = 0;
12749
+ // bumped by barge-in / a new user turn → in-flight decisions drop
12750
+ decidedAt = 0;
12751
+ // word count at the last decision
12752
+ busy = false;
12753
+ pauseTimer = null;
12754
+ bcTimer = null;
12755
+ bridgeTimer = null;
12756
+ lastAt = -Infinity;
12757
+ lastKind = null;
12758
+ live = null;
12759
+ // our last line, until channel-1 audio takes the floor
12760
+ cutLine = null;
12761
+ // the line the last barge actually cut (spoke())
12762
+ bridged = false;
12763
+ // one bridge per main turn
12764
+ recent = [];
12765
+ picks = /* @__PURE__ */ new Map();
12766
+ inflight = null;
12767
+ startedAt;
12768
+ lang = null;
12769
+ carried = 0;
12770
+ // words of this statement already dispatched (a monologue split by STT endpoints)
12771
+ floorWords = 0;
12772
+ // carried + the last dispatched line's words
12773
+ lastHeardAt = -Infinity;
12774
+ pinned = false;
12775
+ // anticipate: the plan for the user's current/next turn (turnNo bumps per dispatched turn → a late plan is dropped)
12776
+ turnNo = 0;
12777
+ plan = null;
12778
+ planning = false;
12779
+ planTries = 0;
12780
+ // per turn: the reply-end plan + one turn-start retry, never a call per interim
12781
+ revised = false;
12782
+ cueFrom = 0;
12783
+ // word index after the last planned backchannel
12784
+ reply = "";
12785
+ // channel-1's text this turn (the plan's context)
12786
+ lastUser = "";
12787
+ lastReactAt = -Infinity;
12788
+ normalTiming = {};
12789
+ // chatty without aec: the preset's keys back at their defaults
12790
+ degradedLogged = false;
12791
+ /** The language lines are spoken in (detected from the user's words, or set by the host). */
12792
+ get language() {
12793
+ return this.lang;
12794
+ }
12795
+ /** Switch language: its bank starts loading in the background. `host` = the host's STT-reported language — it pins
12796
+ * the language (later detection never overrides it; only another host call does). Tags normalize to 'he-IL' → 'he'. */
12797
+ setLanguage(tag, host = false) {
12798
+ if (!tag || this.pinned && !host) return;
12799
+ if (host) this.pinned = true;
12800
+ const lang = primaryLang(tag);
12801
+ if (!lang || lang === this.lang) return;
12802
+ log28.debug(`language ${this.lang} \u2192 ${lang}`);
12803
+ this.lang = lang;
12804
+ this.recent = [];
12805
+ this.picks.clear();
12806
+ void this.o.banks?.ensure(lang);
12807
+ }
12808
+ /** Once a language is known, switching needs strong evidence (detectLanguage's `switching`): an English "Um," read as
12809
+ * Portuguese flipped a live session to pt (silence until a pt bank was generated — it then spoke Portuguese). */
12810
+ detect(text) {
12811
+ if (this.pinned) return;
12812
+ const l = detectLanguage(text, !!this.lang);
12813
+ if (l) this.setLanguage(l);
12814
+ }
12815
+ get bank() {
12816
+ return this.lang ? this.o.banks ? this.o.banks.get(this.lang) : this.lang === "en" ? EN_BANK : null : null;
12817
+ }
12818
+ get o() {
12819
+ const o = this.options;
12820
+ return o.mode !== "chatty" || o.aec ? o : { ...o, ...this.normalTiming };
12821
+ }
12822
+ /** The user's live partial for the current turn (the whole partial, not a delta). */
12823
+ onInterim(text) {
12824
+ const t = text.trim();
12825
+ if (!t || t === this.heard) return;
12826
+ if (this.main !== "idle" || !this.heard) this.carried = this.o.now() - this.lastHeardAt < this.o.continueMs ? this.floorWords : 0;
12827
+ this.lastHeardAt = this.o.now();
12828
+ if (this.main !== "idle") this.onAgentState("idle");
12829
+ this.heard = t;
12830
+ this.detect(t);
12831
+ this.userSpeaking = true;
12832
+ if (this.pauseTimer) clearTimeout(this.pauseTimer);
12833
+ this.pauseTimer = setTimeout(() => {
12834
+ this.pauseTimer = null;
12835
+ this.onPause(this.o.fillerPauseMs);
12836
+ }, this.o.fillerPauseMs);
12837
+ let cue = null;
12838
+ if (this.o.anticipate) {
12839
+ const n = words2(t).length;
12840
+ if (!this.plan && !this.planning && this.planTries < 2) this.requestPlan();
12841
+ else if (this.plan && !this.revised && n >= this.o.reviseAtWords) {
12842
+ this.revised = true;
12843
+ this.requestPlan(t);
12844
+ }
12845
+ cue = matchCue(this.plan, t, Math.max(this.cueFrom, n - this.o.cueWindow));
12846
+ }
12847
+ if (this.bcTimer) clearTimeout(this.bcTimer);
12848
+ this.bcTimer = this.o.aec ? setTimeout(() => {
12849
+ this.bcTimer = null;
12850
+ if (this.userSpeaking && this.heard === t) void this.consider("interim", 0, true, cue);
12851
+ }, cue ? this.o.cuePauseMs : this.o.backchannelPauseMs) : null;
12852
+ if (words2(t).length - this.decidedAt >= this.o.decideEveryWords) void this.consider("interim");
12853
+ }
12854
+ /** The user went quiet for `ms` (host VAD/STT pause, or the internal interim timer). */
12855
+ onPause(ms) {
12856
+ if (this.pauseTimer) {
12857
+ clearTimeout(this.pauseTimer);
12858
+ this.pauseTimer = null;
12859
+ }
12860
+ if (this.bcTimer) {
12861
+ clearTimeout(this.bcTimer);
12862
+ this.bcTimer = null;
12863
+ }
12864
+ this.userSpeaking = false;
12865
+ void this.consider("pause", ms);
12866
+ }
12867
+ /** Channel-1 lifecycle. `queued` = the user's turn was dispatched to the main agent. */
12868
+ onAgentState(s) {
12869
+ if (s === this.main) return;
12870
+ this.main = s;
12871
+ if (s === "first_audio" || s === "speaking") this.live = null;
12872
+ if (s !== "queued" && s !== "thinking" && this.bridgeTimer) {
12873
+ clearTimeout(this.bridgeTimer);
12874
+ this.bridgeTimer = null;
12875
+ }
12876
+ if (s === "idle") {
12877
+ this.heard = "";
12878
+ this.decidedAt = 0;
12879
+ this.bridged = false;
12880
+ this.cueFrom = 0;
12881
+ if (this.o.anticipate && this.reply.trim() && !this.plan && !this.planning) this.requestPlan();
12882
+ return;
12883
+ }
12884
+ if (s === "queued") {
12885
+ this.turnNo++;
12886
+ this.plan = null;
12887
+ this.planning = false;
12888
+ this.planTries = 0;
12889
+ this.revised = false;
12890
+ this.reply = "";
12891
+ this.gen++;
12892
+ this.userSpeaking = false;
12893
+ this.bridged = false;
12894
+ if (this.lastKind === "bridge") this.lastKind = null;
12895
+ for (const k of ["pauseTimer", "bcTimer", "bridgeTimer"]) {
12896
+ if (this[k]) clearTimeout(this[k]);
12897
+ this[k] = null;
12898
+ }
12899
+ const wait = Math.max(this.o.bridgeDelayMs, this.o.minGapMs - (this.o.now() - this.lastAt));
12900
+ if (wait > 0) this.bridgeTimer = setTimeout(() => {
12901
+ this.bridgeTimer = null;
12902
+ void this.consider("working");
12903
+ }, wait);
12904
+ else void this.consider("working");
12905
+ }
12906
+ }
12907
+ /** The user's final line was dispatched to the main agent (its words are the bridge's micro-context). */
12908
+ onUserTurn(text) {
12909
+ this.heard = text.trim();
12910
+ this.floorWords = this.carried + words2(this.heard).length;
12911
+ this.lastUser = this.heard;
12912
+ this.detect(this.heard);
12913
+ this.main = "idle";
12914
+ this.onAgentState("queued");
12915
+ }
12916
+ /** The main answer's streamed text (before its audio): channel-1 is responding — no bridge from here on. */
12917
+ onMainTokens(text) {
12918
+ this.noteReply(text);
12919
+ if (text.trim() && (this.main === "queued" || this.main === "thinking")) this.onAgentState("streaming_text");
12920
+ }
12921
+ /** Channel-1's reply text (a delta) — only the plan's context; no state change (hosts without onMainTokens). */
12922
+ noteReply(text) {
12923
+ this.reply += text;
12924
+ }
12925
+ /** The user barged in: cut the in-flight line now and drop every pending decision. */
12926
+ onBargeIn() {
12927
+ this.gen++;
12928
+ this.cutLine = this.inflight || this.live ? this.live : null;
12929
+ if (this.inflight) {
12930
+ this.inflight = null;
12931
+ try {
12932
+ this.o.cancel();
12933
+ } catch (e) {
12934
+ log28.warn(`cancel threw: ${e.message}`);
12935
+ }
12936
+ }
12937
+ }
12938
+ /** Is `text` the line the last barge-in cut (ours, still holding the floor)? Hosts strip it from the cut reply — it is
12939
+ * not the agent's speech. By identity, not by text: a real reply that merely repeats a recent line is kept. Consumed once. */
12940
+ spoke(text) {
12941
+ const c = this.cutLine;
12942
+ this.cutLine = null;
12943
+ return !!c && c === text.trim();
12944
+ }
12945
+ /** Resolves when the in-flight line finished playing (immediately when none) — hosts enqueue channel-1 audio behind it. */
12946
+ drain() {
12947
+ return this.inflight ?? Promise.resolve();
12948
+ }
12949
+ /** Channel-1's last reply ended on a CLOSED question (yes/no, "X or Y?", a confirm): the user's current turn answers or
12950
+ * corrects it. An open invitation ("what happened?") is different — a story follows, and reactions fit it. */
12951
+ get answeringUs() {
12952
+ return this.main === "idle" && closedQuestion(this.reply);
12953
+ }
12954
+ get audioLocked() {
12955
+ return this.main === "first_audio" || this.main === "speaking";
12956
+ }
12957
+ tail() {
12958
+ return words2(this.heard).slice(-this.o.microWords).join(" ");
12959
+ }
12960
+ /** Hard gates every line passes, whoever proposed it. */
12961
+ allowed(k) {
12962
+ if (!this.o.kinds.includes(k)) return false;
12963
+ if (this.audioLocked || this.inflight || this.o.now() - this.lastAt < this.o.minGapMs || k === this.lastKind && (k !== "backchannel" || this.o.now() - this.lastAt < this.o.backchannelGapMs)) return false;
12964
+ if (k === "backchannel") return this.o.aec && this.userSpeaking && this.main === "idle" && !this.capped();
12965
+ if (k === "filler") return !this.userSpeaking && this.main === "idle";
12966
+ return (this.main === "queued" || this.main === "thinking") && !this.bridged;
12967
+ }
12968
+ bcTimes = [];
12969
+ capped() {
12970
+ if (!this.o.maxPerWindow) return false;
12971
+ const from = this.o.now() - this.o.windowMs;
12972
+ this.bcTimes = this.bcTimes.filter((t) => t > from);
12973
+ return this.bcTimes.length >= this.o.maxPerWindow;
12974
+ }
12975
+ /** 0ms rule: the candidate line for this trigger, or silence. */
12976
+ rule(trigger, pauseMs = 0, breath = false, cue = null) {
12977
+ const w = words2(this.heard);
12978
+ if (!this.lang) return "silence";
12979
+ if (w.length <= 3 || w.length <= 6 && isPhatic(this.heard, this.bank)) return "silence";
12980
+ const k = trigger === "interim" ? this.backchannelMoment(w.length, breath) && (!this.o.anticipate || !this.plan || !silentTurn(this.plan) && (!!cue || !this.o.cueOnly && this.plan.density !== "low")) ? "backchannel" : null : trigger === "pause" ? pauseMs >= this.o.fillerPauseMs && w.length >= this.o.fillerMinWords ? "filler" : null : w.length >= this.o.bridgeMinWords ? "bridge" : null;
12981
+ return k && this.allowed(k) ? k : "silence";
12982
+ }
12983
+ /** Plan (or, with `heard`, revise) the backchannels of the user's turn — async, never on the hot path. */
12984
+ requestPlan(heard) {
12985
+ if (!this.o.plan || !this.lang) return;
12986
+ const turn = this.turnNo, t0 = this.o.now(), ac = new AbortController();
12987
+ if (!heard) {
12988
+ this.planning = true;
12989
+ this.planTries++;
12990
+ }
12991
+ const ctx = { agent: this.reply.trim(), user: words2(this.lastUser).slice(-30).join(" "), recent: [...this.recent], lang: this.lang, heard };
12992
+ void withBudget(this.o.plan(ctx, ac.signal), this.o.planBudgetMs, heard ? "plan revision" : "plan").then((p) => {
12993
+ if (p === null) ac.abort();
12994
+ if (turn !== this.turnNo) return;
12995
+ if (!heard) this.planning = false;
12996
+ if (!p) return;
12997
+ this.plan = p;
12998
+ this.o.onPlan(p, !!heard, Math.round(this.o.now() - t0));
12999
+ });
13000
+ }
13001
+ /** A clause boundary mid-floor: a breath or a comma-like close. Not a question (it wants the answer, not "mm-hm"), and a
13002
+ * sentence end only deep into a story — a short request's end is the end of their turn (replay: "mm-hm" right after
13003
+ * "Check hacker news for recent news.", just before the final). */
13004
+ backchannelMoment(n, breath) {
13005
+ if (/\?$/.test(this.heard) || pendingQuestion(this.heard)) return false;
13006
+ if (this.carried) n += this.carried;
13007
+ else if (n < this.o.requestHoldWords && requestOpener(this.heard)) return false;
13008
+ if (n < this.o.backchannelMinWords) return false;
13009
+ if (/[.!]$/.test(this.heard)) return n >= 2 * this.o.backchannelMinWords;
13010
+ return breath || /[,;:…]$/.test(this.heard);
13011
+ }
13012
+ async consider(trigger, pauseMs, breath = false, cue = null) {
13013
+ if (this.busy) return;
13014
+ const n0 = words2(this.heard).length;
13015
+ this.decidedAt = n0;
13016
+ if (!(this.o.anticipate && this.plan && trigger === "interim")) cue = null;
13017
+ const opts = this.options;
13018
+ if (opts.mode === "chatty" && !opts.aec && !this.degradedLogged) {
13019
+ this.degradedLogged = true;
13020
+ log28.info("chatty mode without echo cancellation \u2014 normal timing (our lines would re-trigger the mic)");
13021
+ }
13022
+ const rule = this.rule(trigger, pauseMs, breath, cue);
13023
+ if (rule === "silence") return;
13024
+ this.busy = true;
13025
+ const gen = this.gen, t0 = this.o.now();
13026
+ const ev = (e) => this.o.onEvent({ trigger, rule, ms: Math.round(this.o.now() - t0), ...cue ? { cue: cue.line } : {}, ...e });
13027
+ const stale = () => gen !== this.gen || words2(this.heard).length - n0 >= this.o.staleWords;
13028
+ try {
13029
+ let kind = rule;
13030
+ let choice;
13031
+ if (this.o.decide && !cue) {
13032
+ const ac = new AbortController();
13033
+ const d = await withBudget(this.o.decide(this.tail(), { ...this.state(pauseMs), rule }, ac.signal), this.o.decisionBudgetMs, "channel decision", ac);
13034
+ choice = d?.choice;
13035
+ if (choice && choice in CHANNEL_ACTIONS && (choice === "silence" || this.allowed(choice))) kind = choice;
13036
+ else if (choice && choice in CHANNEL_ACTIONS) log28.debug(`decider chose ${choice} for a ${rule} moment \u2014 the rule stands`);
13037
+ }
13038
+ if (kind === "silence") return ev({ choice, acted: "silence" });
13039
+ if (stale()) return ev({ choice, kind, acted: "stale" });
13040
+ if (!this.allowed(kind)) return ev({ choice, kind, acted: "gated" });
13041
+ const react = kind === "backchannel" && !this.answeringUs && this.o.now() - this.lastReactAt >= this.o.reactionGapMs;
13042
+ const text = cue && react && !this.recent.some((r) => norm3(r) === norm3(cue.line)) ? cue.line : await this.line(kind, react);
13043
+ if (!text) return ev({ choice, kind, acted: "no_bank" });
13044
+ if (stale()) return ev({ choice, kind, text, acted: "stale" });
13045
+ if (!this.allowed(kind)) return ev({ choice, kind, text, acted: "gated" });
13046
+ const said = this.say(kind, text);
13047
+ if (said && cue) this.cueFrom = words2(this.heard).length;
13048
+ if (said && kind === "backchannel" && this.o.reactionGapMs && !this.bank?.backchannel.some((b) => norm3(b) === norm3(text))) this.lastReactAt = this.o.now();
13049
+ ev({ choice, kind, text, acted: said ? "spoken" : "skipped" });
13050
+ } finally {
13051
+ this.busy = false;
13052
+ }
13053
+ }
13054
+ state(pauseMs) {
13055
+ const now3 = this.o.now();
13056
+ return {
13057
+ user_speaking: this.userSpeaking,
13058
+ pause_ms: pauseMs ?? null,
13059
+ agent_state: this.main,
13060
+ last_line: this.recent.at(-1) ?? null,
13061
+ ms_since_last_line: Number.isFinite(this.lastAt) ? Math.round(now3 - this.lastAt) : null,
13062
+ call_ms: Math.round(now3 - this.startedAt)
13063
+ };
13064
+ }
13065
+ async line(kind, react = true) {
13066
+ if (this.o.generate && this.o.generateFor.includes(kind) && (kind !== "backchannel" || react)) {
13067
+ const ac = new AbortController();
13068
+ const ctx = { kind, heard: this.tail(), state: this.main, avoid: [...this.recent], lang: this.lang, ...kind === "backchannel" && this.o.reactionGapMs ? { react: true } : {} };
13069
+ const raw = await withBudget(this.o.generate(ctx, ac.signal), this.o.generateBudgetMs, `${kind} generate`);
13070
+ if (raw === null) ac.abort();
13071
+ const text = cleanLine(raw ?? "", this.lang);
13072
+ if (text && !this.recent.some((r) => norm3(r) === norm3(text))) return text;
13073
+ if (text) log28.debug(`generated "${text}" repeats a recent line \u2014 canned`);
13074
+ }
13075
+ return this.pick(kind);
13076
+ }
13077
+ /** Canned rotation in the user's language: never one of the last `bankExclude` picks of this kind, nor a recent
13078
+ * line. '' = no bank for this language yet (silence). */
13079
+ pick(kind) {
13080
+ const pool = this.bank?.[kind] ?? [];
13081
+ if (!pool.length) return "";
13082
+ const hist = this.picks.get(kind) ?? [];
13083
+ const recent = new Set([...hist.slice(-this.o.bankExclude), ...this.recent].map(norm3));
13084
+ const fresh = pool.filter((x) => !recent.has(norm3(x)));
13085
+ const from = fresh.length ? fresh : pool;
13086
+ const p = from[Math.floor(this.o.random() * from.length)];
13087
+ this.picks.set(kind, [...hist, p].slice(-this.o.bankExclude));
13088
+ return p;
13089
+ }
13090
+ say(kind, text) {
13091
+ let r;
13092
+ try {
13093
+ r = this.o.speak(text, kind);
13094
+ } catch (e) {
13095
+ log28.warn(`speak threw \u2014 line dropped: ${e.message}`);
13096
+ return false;
13097
+ }
13098
+ if (r === false) return false;
13099
+ this.lastAt = this.o.now();
13100
+ this.lastKind = kind;
13101
+ if (kind === "backchannel") this.bcTimes.push(this.lastAt);
13102
+ if (kind === "bridge") this.bridged = true;
13103
+ this.recent = [...this.recent, text].slice(-this.o.avoidLines);
13104
+ this.live = text.trim();
13105
+ this.o.show(text, kind);
13106
+ const p = Promise.resolve(r).then(() => {
13107
+ }, (e) => {
13108
+ log28.warn(`channel line playback failed: ${e?.message ?? e}`);
13109
+ }).finally(() => {
13110
+ if (this.inflight === p) this.inflight = null;
13111
+ });
13112
+ this.inflight = p;
13113
+ return true;
13114
+ }
13115
+ };
13116
+ var FAST_ASK = {
13117
+ backchannel: 'The user is still talking and just took a breath. Would an attentive human listener say a tiny "mm-hm" now? Yes ONLY if they are telling a story/explaining across several clauses. No if it is a request, command, question, or they are mid-request.',
13118
+ filler: 'The user went quiet mid-turn. Would a human listener make a soft "hmm\u2026" now? Yes ONLY if their sentence is unfinished and they are searching for words. No if they said a complete request, question, answer, thanks or remark (the reply comes next).',
13119
+ bridge: `The user finished and the assistant's answer is taking a moment. Would a human say a short "let me check\u2014" now? Yes if they asked for real work or a substantial/hard question. No for small talk, a greeting, thanks, an ok/yes/agreement.`
13120
+ };
13121
+ function fastChannelDecider(ai, model = "groq/qwen/qwen3.8-27b") {
13122
+ return async (heard, state, signal) => {
13123
+ const kind = state.rule;
13124
+ if (!(kind in FAST_ASK)) return null;
13125
+ const prompt = `Live voice call. ${FAST_ASK[kind]}
13126
+ So far the user said: "${heard}"
13127
+ Answer yes or no.`;
13128
+ const reasoning = /qwen/.test(model) ? { providerOptions: { reasoning_effort: "none" } } : /gpt-oss/.test(model) ? { providerOptions: { reasoning_effort: "low" } } : {};
13129
+ const r = await ai.chat({ model, messages: [{ role: "user", content: prompt }], stream: false, signal, temperature: 0, maxTokens: /gpt-oss/.test(model) ? 200 : 3, ...reasoning });
13130
+ const a = (r.content ?? "").trim().toLowerCase();
13131
+ return /^yes/.test(a) ? { choice: kind } : /^no/.test(a) ? { choice: "silence" } : null;
13132
+ };
13133
+ }
13134
+ function channelDecide(ai, instinct, env = process.env) {
13135
+ const model = env.AGENTX_CHANNEL_DECIDER_MODEL || void 0;
13136
+ if (model || env.GROQ_API_KEY) return fastChannelDecider(ai, model);
13137
+ return instinct ? (heard, state) => instinct.channel(heard, state) : void 0;
13138
+ }
13139
+ var KIND_HINT2 = {
13140
+ backchannel: 'a tiny listener cue while they keep talking ("mm-hm", "right", "yeah") \u2014 shows you follow, never takes the turn',
13141
+ filler: 'a soft listener sound while THEY search for words ("mm\u2026", "mhm", "yeah\u2026") \u2014 never a word that continues their sentence or starts yours',
13142
+ bridge: 'a neutral one-moment cue while the answer is prepared ("let me see\u2026", "one sec\u2026") \u2014 never the start of a statement or opinion ("the thing is", "well actually")'
13143
+ };
13144
+ var REACT_HINT = 'a short reaction to their LAST words instead: feeling ("oh no", "ha, nice", "ugh"), surprise ("wait, really?", "huh"), a light laugh if they joke or laugh ("ha!"), or an echo of their key word ("the deploy?") \u2014 still never asks for an answer or takes the turn; if nothing there deserves a reaction, a plain "mm-hm"';
13145
+ function fastLineGenerator(ai, model) {
13146
+ return async (c, signal) => {
13147
+ const prompt = `You voice the tiny side-sounds of a live spoken conversation. Write ONE line of 1-5 words: ${KIND_HINT2[c.kind]}.
13148
+ ` + (c.react ? `Here the line may be ${REACT_HINT}.
13149
+ ` : "") + `The user just said: "${c.heard}"
13150
+ The assistant is: ${c.state}.
13151
+ ` + (c.avoid.length ? `Do not reuse any of: ${c.avoid.map((a) => `"${a}"`).join(", ")}.
13152
+ ` : "") + `React to their actual words, casually, in ${languageName(c.lang)}. Output only the line.`;
13153
+ const reasoning = /gpt-oss/.test(model) ? { providerOptions: { reasoning_effort: "low" } } : {};
13154
+ const r = await ai.chat({ model, messages: [{ role: "user", content: prompt }], stream: false, signal, maxTokens: 150, ...reasoning });
13155
+ return r.content ?? "";
13156
+ };
13157
+ }
13158
+ function closedQuestion(reply) {
13159
+ const t = reply.replace(/\[[^\]]*\]|<\/?[a-z]+>/gi, " ").replace(/\s+/g, " ").trim();
13160
+ if (!/\?["'’”)]*$/.test(t)) return false;
13161
+ const q = t.split(/(?<=[.!?])\s+(?=\S)/).pop().replace(/^(?:(?:so|ok(?:ay)?|and|but|just to (?:confirm|check)|to confirm|quick (?:check|question)|got it)[,:—–-]*\s+)+/i, "");
13162
+ return /^(?:should|shall|do|does|did|is|are|was|were|can|could|would|will|may|have|has|want|wanna)\b/i.test(q) || /\bor\b/i.test(q);
13163
+ }
13164
+
13165
+ // src/voice/call.ts
13166
+ var log29 = forComponent("VoiceCall");
13167
+ var VoiceCallOptions = class {
13168
+ /** Host runs MAIN with exactly `text` (a replace: roll the `replaces` turn back first). */
13169
+ sendTurn = () => {
13170
+ };
13171
+ /** Host aborts a running MAIN turn (barge-in). */
13172
+ abortTurn = () => {
13173
+ };
13174
+ /** Host TTS (fill lane): resolves when played; `false` = could not play. */
13175
+ speak = () => false;
13176
+ /** Stop the in-flight fill line now (barge-in only). */
13177
+ cancel = () => {
13178
+ };
13179
+ /** Host caption — never history. */
13180
+ show = () => {
13181
+ };
13182
+ /** The fast model (lines, banks, progress) — used only with `ai`. */
13183
+ fastModel = latestClaude("haiku");
13184
+ /** Model client for the fast model + the channel decider / overlap judge. Absent → canned lines and rules only. */
13185
+ ai;
13186
+ /** Cache dir for per-language banks (ChannelBanks). */
13187
+ bankCacheDir;
13188
+ /** The host STT's language (BCP-47) — the starting language; each turn's own words still decide. */
13189
+ lang;
13190
+ aec = false;
13191
+ mode = "normal";
13192
+ gate = {};
13193
+ progress = {};
13194
+ /** Extra SecondChannel options (tests, tuning). speak/show/cancel are always VoiceCall's own. */
13195
+ channel = {};
13196
+ onMetric = () => {
13197
+ };
13198
+ now = () => performance.now();
13199
+ };
13200
+ var VoiceCall = class {
13201
+ constructor(options) {
13202
+ this.options = options;
13203
+ this.options = { ...new VoiceCallOptions(), ...options };
13204
+ const o = this.o;
13205
+ if (o.lang) this.lang = primaryLang(o.lang);
13206
+ const ai = o.ai;
13207
+ this.fill = new SecondChannel({
13208
+ ...ai ? {
13209
+ banks: new ChannelBanks({ generate: fastBankGenerator(ai, o.fastModel), ...o.bankCacheDir ? { cacheDir: o.bankCacheDir } : {} }),
13210
+ decide: channelDecide(ai),
13211
+ generate: fastLineGenerator(ai, o.fastModel)
13212
+ } : {},
13213
+ mode: o.mode,
13214
+ aec: o.aec,
13215
+ ...this.lang ? { lang: this.lang } : {},
13216
+ ...o.channel,
13217
+ speak: (text, kind) => this.say(text, kind),
13218
+ show: (text, kind) => o.show(text, kind),
13219
+ cancel: () => o.cancel(),
13220
+ now: o.now
13221
+ });
13222
+ this.progress = new Progress({ ...ai ? { generate: fastProgressGenerator(ai, o.fastModel) } : {}, now: o.now, ...o.progress });
13223
+ this.judge = new OverlapJudge({ ...ai ? { model: overlapModelFor(ai) } : {}, now: o.now });
13224
+ this.gate = new TurnGate({
13225
+ now: o.now,
13226
+ ...o.gate,
13227
+ onTurn: (t) => this.onTurn(t),
13228
+ onDrop: (text, why) => this.onDrop(text, why),
13229
+ onHold: (text) => o.onMetric({ name: "hold", text })
13230
+ });
13231
+ }
13232
+ options;
13233
+ fill;
13234
+ gate;
13235
+ progress;
13236
+ judge;
13237
+ cur;
13238
+ lang = null;
13239
+ lastAudibleAt = -Infinity;
13240
+ firstFinalAt = null;
13241
+ // the first final of the turn being formed (hold/merge)
13242
+ progressTimer = null;
13243
+ ended = false;
13244
+ get o() {
13245
+ return this.options;
13246
+ }
13247
+ /** The language the fill channel speaks right now (null = unknown → silent). */
13248
+ get language() {
13249
+ return this.lang;
13250
+ }
13251
+ /** The MAIN turn in flight (or the last one). */
13252
+ get turn() {
13253
+ return this.cur && { id: this.cur.id, text: this.cur.text, running: this.cur.running };
13254
+ }
13255
+ /** The host STT's language (BCP-47): the current language until the user's words say otherwise. */
13256
+ setLanguage(tag) {
13257
+ const l = primaryLang(tag);
13258
+ if (!l) return;
13259
+ this.lang = l;
13260
+ this.fill.setLanguage(l, true);
13261
+ }
13262
+ /** STT partial (the whole partial). */
13263
+ interim(text, _atMs, aec) {
13264
+ if (this.ended || !text.trim()) return;
13265
+ if (typeof aec === "boolean") this.fill.options.aec = aec;
13266
+ this.gate.speaking();
13267
+ this.follow(text);
13268
+ this.fill.onInterim(text);
13269
+ }
13270
+ /** STT final → TurnGate. */
13271
+ final(text, sttId) {
13272
+ if (this.ended) return;
13273
+ this.firstFinalAt ??= this.o.now();
13274
+ this.gate.final(text, sttId);
13275
+ }
13276
+ /** A v2 host that decides turns itself: the user's line already went to MAIN (no TurnGate, no sendTurn) — the fill
13277
+ * channel follows its language and covers the wait. */
13278
+ hostTurn(text) {
13279
+ if (this.ended || !text.trim()) return;
13280
+ this.follow(text);
13281
+ this.fill.onUserTurn(text);
13282
+ }
13283
+ pause(ms) {
13284
+ if (!this.ended) this.fill.onPause(ms);
13285
+ }
13286
+ /** MAIN's stream. */
13287
+ main(ev) {
13288
+ const c = this.cur;
13289
+ if (!c || ev.id && ev.id !== c.id) return;
13290
+ switch (ev.type) {
13291
+ case "start":
13292
+ c.running = true;
13293
+ return;
13294
+ case "text":
13295
+ if (!ev.text) return;
13296
+ if (!c.streamed) {
13297
+ c.streamed = true;
13298
+ this.gate.replyStarted(c.id);
13299
+ this.progress.text();
13300
+ this.clearProgress();
13301
+ }
13302
+ this.fill.onMainTokens(ev.text);
13303
+ return;
13304
+ case "tool":
13305
+ this.gate.replyStarted(c.id);
13306
+ void this.onTool(c, ev.name ?? "", ev.args);
13307
+ return;
13308
+ case "tool_done":
13309
+ return;
13310
+ case "audio":
13311
+ this.lastAudibleAt = this.o.now();
13312
+ this.fill.onAgentState("first_audio");
13313
+ this.sounded(c, "main");
13314
+ if (!c.worded) {
13315
+ c.worded = true;
13316
+ this.o.onMetric({ name: "first_word", id: c.id, ms: Math.round(this.o.now() - c.at), by: "main" });
13317
+ }
13318
+ return;
13319
+ case "done":
13320
+ case "error":
13321
+ c.running = false;
13322
+ this.clearProgress();
13323
+ this.fill.onAgentState("idle");
13324
+ return;
13325
+ }
13326
+ }
13327
+ /** The user barged in (hard): cut fill + MAIN audio (the host cuts its own), abort a running MAIN turn. */
13328
+ barge() {
13329
+ this.fill.onBargeIn();
13330
+ this.clearProgress();
13331
+ try {
13332
+ this.o.cancel();
13333
+ } catch (e) {
13334
+ log29.warn(`cancel threw: ${e.message}`);
13335
+ }
13336
+ if (this.cur?.running) {
13337
+ this.cur.running = false;
13338
+ this.o.abortTurn(this.cur.id);
13339
+ }
13340
+ }
13341
+ /** Soft barge-in: keep (a listener cue — no turn) or cut (→ barge()). */
13342
+ async overlap(text) {
13343
+ const d = await this.judge.judge(text);
13344
+ if (d.verdict === "cut") this.barge();
13345
+ return d.verdict;
13346
+ }
13347
+ /** Did the last barge cut OUR fill line `text`? (hosts strip it from the cut reply — it is not MAIN's speech) */
13348
+ spoke(text) {
13349
+ return this.fill.spoke(text);
13350
+ }
13351
+ /** Resolves when the in-flight fill line finished — hosts queue MAIN audio behind it (stack, don't cut). */
13352
+ drain() {
13353
+ return this.fill.drain();
13354
+ }
13355
+ end() {
13356
+ this.ended = true;
13357
+ this.gate.end();
13358
+ this.clearProgress();
13359
+ }
13360
+ // ---- internals ----
13361
+ /** Follow the user's language from their own words: a confident detection, else the current language if the words
13362
+ * fit it, else unknown (silent). Pinned into the channel so its own detection never diverges. */
13363
+ follow(text) {
13364
+ const l = detectLanguage(text) ?? (this.lang && fitsLanguage(text, this.lang) ? this.lang : null);
13365
+ if (l !== this.lang) this.o.onMetric({ name: "lang", lang: l, text });
13366
+ this.lang = l;
13367
+ if (l) this.fill.setLanguage(l, true);
13368
+ }
13369
+ onTurn(t) {
13370
+ const at = this.firstFinalAt ?? this.o.now();
13371
+ this.firstFinalAt = null;
13372
+ this.follow(t.text);
13373
+ this.clearProgress();
13374
+ this.cur = { id: t.id, text: t.text, at, running: true, streamed: false, sounded: false, worded: false };
13375
+ this.progress.turn();
13376
+ this.o.onMetric({ name: "turn", id: t.id, kind: t.kind, text: t.text });
13377
+ this.o.sendTurn(t.text, { id: t.id, ...t.replaces ? { replaces: t.replaces } : {} });
13378
+ if (t.kind === "new") this.fill.onUserTurn(t.text);
13379
+ }
13380
+ onDrop(text, why) {
13381
+ if (!this.gate.holding) this.firstFinalAt = null;
13382
+ this.o.onMetric({ name: "drop", kind: why, text });
13383
+ }
13384
+ /** Every fill/progress line passes here: only in the turn's known language, never after the call ended. */
13385
+ say(text, kind) {
13386
+ if (this.ended || !this.lang || this.fill.language !== this.lang) return false;
13387
+ let r;
13388
+ try {
13389
+ r = this.o.speak(text, kind);
13390
+ } catch (e) {
13391
+ log29.warn(`speak threw: ${e.message}`);
13392
+ return false;
13393
+ }
13394
+ if (r === false) return false;
13395
+ this.lastAudibleAt = this.o.now();
13396
+ this.o.onMetric({ name: "fill", kind, text, lang: this.lang });
13397
+ if (this.cur && (kind === "bridge" || kind === "progress")) this.sounded(this.cur, kind === "progress" ? "progress" : "fill");
13398
+ else if (this.cur && this.cur.running && !this.cur.sounded && kind === "filler") this.sounded(this.cur, "fill");
13399
+ return r;
13400
+ }
13401
+ sounded(c, by) {
13402
+ if (c.sounded) return;
13403
+ c.sounded = true;
13404
+ this.o.onMetric({ name: "first_sound", id: c.id, ms: Math.round(this.o.now() - c.at), by });
13405
+ }
13406
+ async onTool(c, name, args) {
13407
+ const lang = this.lang;
13408
+ if (!lang || c.streamed) return;
13409
+ const line = await this.progress.line(name, args, lang);
13410
+ if (!line || this.cur !== c || c.streamed || !c.running || this.lang !== lang) return;
13411
+ this.clearProgress();
13412
+ const tryNow = () => {
13413
+ this.progressTimer = null;
13414
+ if (this.ended || this.cur !== c || c.streamed || !c.running) return;
13415
+ if (!this.progress.due(this.lastAudibleAt)) {
13416
+ this.progressTimer = setTimeout(tryNow, 250);
13417
+ return;
13418
+ }
13419
+ if (this.say(line, "progress") !== false) {
13420
+ this.progress.spoke(line);
13421
+ this.o.show(line, "progress");
13422
+ }
13423
+ };
13424
+ tryNow();
13425
+ }
13426
+ clearProgress() {
13427
+ if (this.progressTimer) {
13428
+ clearTimeout(this.progressTimer);
13429
+ this.progressTimer = null;
13430
+ }
13431
+ }
13432
+ };
13433
+
13434
+ // src/index.ts
11846
13435
  init_llm();
11847
13436
  import { MemFilesystem as MemFilesystem3, IndexedDbFilesystem, CommandExecutor as CommandExecutor2, registerHeadlessCommands as registerHeadlessCommands2 } from "@livx.cc/wcli/core";
11848
13437
  export {
@@ -11876,8 +13465,11 @@ export {
11876
13465
  MountFilesystem,
11877
13466
  NodeDiskFilesystem,
11878
13467
  OverlayFilesystem,
13468
+ PROGRESS_TEMPLATES,
11879
13469
  PermissionOptions,
11880
13470
  PermissionPolicy,
13471
+ Progress,
13472
+ ProgressOptions,
11881
13473
  RecordingHooks,
11882
13474
  RecordingLifecycle,
11883
13475
  SCRATCH_DIR,
@@ -11889,8 +13481,12 @@ export {
11889
13481
  SonioxSTT,
11890
13482
  SonioxSTTOptions,
11891
13483
  TTS_SAMPLE_RATE,
13484
+ TurnGate,
13485
+ TurnGateOptions,
11892
13486
  VOICE_MEMORY_PROMPT,
11893
13487
  VOICE_SYSTEM_PROMPT,
13488
+ VoiceCall,
13489
+ VoiceCallOptions,
11894
13490
  VoiceEngine,
11895
13491
  VoiceEngineOptions,
11896
13492
  applyEditsTool,
@@ -11914,6 +13510,7 @@ export {
11914
13510
  elideImages,
11915
13511
  elideInlineImages,
11916
13512
  elideStaleImages,
13513
+ endsMidPhrase,
11917
13514
  enterWorktree,
11918
13515
  estimateTokens,
11919
13516
  exitSessionTool,
@@ -11924,6 +13521,7 @@ export {
11924
13521
  expandTemplate,
11925
13522
  findGitRoot,
11926
13523
  forComponent,
13524
+ forgetFact,
11927
13525
  formatJobNotice,
11928
13526
  fullAgentOptions,
11929
13527
  getOrCreateWorktree,
@@ -11935,6 +13533,7 @@ export {
11935
13533
  imagePart,
11936
13534
  imageRefPart,
11937
13535
  imageRefResult,
13536
+ isClosing,
11938
13537
  jevAvailable,
11939
13538
  jevClassifier,
11940
13539
  latestClaude,