@livx.cc/agentx 0.99.95 → 0.99.97

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -7694,12 +7694,12 @@ var VoiceEngine = class _VoiceEngine {
7694
7694
  if (!m && bare.length < 80) return "";
7695
7695
  this.ackProbe = null;
7696
7696
  this.adaptiveAckFired = false;
7697
- const lead = m ? m[1] : bare;
7698
- const norm3 = lead.replace(/[^a-z ]/gi, " ").replace(/\s+/g, " ").trim().toLowerCase();
7697
+ const lead2 = m ? m[1] : bare;
7698
+ const norm3 = lead2.replace(/[^a-z ]/gi, " ").replace(/\s+/g, " ").trim().toLowerCase();
7699
7699
  const same = this.ackPickedFired ? bare.match(_VoiceEngine.leadingPhrase(this.lastAck)) : null;
7700
7700
  if (_VoiceEngine.ACK_LEADING.test(norm3)) {
7701
7701
  const rest = m ? m[2] : "";
7702
- this.diag("reflex_ack_suppressed", { lead: lead.trim() });
7702
+ this.diag("reflex_ack_suppressed", { lead: lead2.trim() });
7703
7703
  if (!rest.trim()) return "";
7704
7704
  text = rest;
7705
7705
  } else if (same) {
@@ -9728,6 +9728,17 @@ function refusedCall(e) {
9728
9728
  const code = Number(e?.statusCode ?? e?.status);
9729
9729
  return code === 401 || code === 403 || /API key|unauthori[sz]ed|authentication|invalid x-api-key|permission denied/i.test(String(e?.message ?? e ?? ""));
9730
9730
  }
9731
+ function rateLimited(e) {
9732
+ 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 ?? ""));
9733
+ }
9734
+ var LEAD_TAGS = String.raw`^(?:\s*\[[^\]]*\]\s*)*\s*`;
9735
+ 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");
9736
+ var DONE_CLAIM_RE = new RegExp(LEAD_TAGS + String.raw`(?:(?:all\s+)?done|finished|it['’]?s\s+(?:done|finished)|completed?)\b`, "i");
9737
+ function wordOverlap(sentence, prior) {
9738
+ const words3 = (x) => x.replace(/\[[^\]]*\]/g, " ").toLowerCase().replace(/['’]/g, "").match(new RegExp("\\p{L}{4,}", "gu")) ?? [];
9739
+ const have = new Set(words3(prior)), ws = words3(sentence);
9740
+ return ws.length ? ws.filter((w) => have.has(w)).length / ws.length : 0;
9741
+ }
9731
9742
  var DuplexAgentOptions = class {
9732
9743
  /** Any ai.libx.js AIClient — shared by all tiers (routed by model). */
9733
9744
  ai;
@@ -9737,6 +9748,10 @@ var DuplexAgentOptions = class {
9737
9748
  // mislabels gpt-oss harmony channels under load, leaking raw analysis into the spoken `final` channel
9738
9749
  // (and misfiring Hold). 120b is the same price tier (~$0.15/$0.60) — the quality/cost trade is free.
9739
9750
  reflexModel = "groq/openai/gpt-oss-120b";
9751
+ /** A reflex turn REJECTED for load (429 / TPM limit — live 20261013-152733: the org's 120b TPM ran out and the user heard
9752
+ * "could you say that again?") is re-run once on this model — on groq a sibling model has its own rate limit. Only used
9753
+ * for a reflex on the same provider (its key is known to work). Off: ''. */
9754
+ reflexFallbackModel = "groq/openai/gpt-oss-20b";
9740
9755
  actModel = latestClaude("sonnet");
9741
9756
  /** Premium reasoning model. Set to `false` to disable the Think tier entirely. */
9742
9757
  thinkModel = latestClaude("opus");
@@ -9805,6 +9820,9 @@ var DuplexAgentOptions = class {
9805
9820
  /** Extra roots QuickLook ls/file try for a relative path not found under the cwd (the CLI passes ~ and ~/Documents:
9806
9821
  * a spoken "Projects/X" means ~/Documents/Projects/X far more often than <cwd>/Projects/X). */
9807
9822
  lookRoots = [];
9823
+ /** The words the speech-to-text is primed with (names, companies, projects — e.g. memoryTerms → Soniox context.terms), read
9824
+ * live: QuickLook `capabilities` lists them, so "do you have pre-recognized words?" is answered from fact (live 20261013-152733). */
9825
+ sttTerms;
9808
9826
  /** Memory directory/directories on the WORKER fs. If set, the voice agent gets Remember + Recall
9809
9827
  * tools directly (no delegation needed) and implicit capture guidance. */
9810
9828
  memoryDir;
@@ -9888,7 +9906,7 @@ function isOfferAcceptance(text) {
9888
9906
  }
9889
9907
  var isQuestion = (s) => /\?["'”’)\]]*$/.test(s.trim());
9890
9908
  var OFFER_OVERRUN = 150;
9891
- var VOICE_SYSTEM_PROMPT = 'You are a spoken voice assistant \u2014 the user HEARS everything you say. Use short sentences. One idea per sentence. No markdown, no bullet lists, no code blocks, no headings, no emoji. Never emit stage directions or parenthetical asides about your own process \u2014 nothing like "(waiting for the result...)" or "(checking)"; while work runs, either say it as plain speech or end your turn.\nThis holds even when asked to "print", "list", "show", or "make a table" \u2014 there is no screen for the spoken channel. Speak it as flowing prose ("Tuesday is half a meter, Wednesday a bit less\u2026"), or if they truly need it on screen, route it to Act to render. Never emit dashes or pipes into speech.\nKeep turns SHORT \u2014 one to three sentences, then stop. Never lecture, enumerate cases, or add caveats unprompted. Conversation is a fast exchange: give the one thing asked, and let the user pull more if they want it.\nYou have three cognitive tiers \u2014 like a human brain:\n\u2022 YOU (reflex) \u2014 instant, lightweight. Handle greetings, simple questions, status checks, QuickLook.\n\u2022 `Act` \u2014 your hands. A background worker with its own configured tools and access to the user\'s environment (files and shell{{WORKER_WEB}}). Use for reading, editing, searching, running tasks, building \u2014 any real work.\n{{THINK_SLOT}}\nWhen you are unsure whether you can do or access something, do NOT assume and do NOT claim a capability you have not confirmed. To check what you can do, QuickLook `capabilities` (instant \u2014 it lists your worker\'s real tools) and answer from that. Never promise an ability that is not in your capabilities; if it is not there, tell the user plainly you can\'t. To actually DO real work, call `Act`. When the user mentions their project, folder, files, or environment ("this project", "the current folder", "my code"), call `Act` IMMEDIATELY \u2014 do not ask for paths or details the worker can discover itself. Never pretend to have done the work or invent results \u2014 the worker\'s report is your only source.\nYou cannot mute the microphone or stop voice capture yourself \u2014 no tool does it. If the user asks you to stop listening or turn the voice off, never claim you did: tell them to say exactly "voice off" (handled by the app directly), or type /voice.\nYou are NOT a knowledge base. For any question whose answer needs SPECIFIC verifiable facts you do not already have in hand \u2014 how to build/configure/implement something, exact API, library, entitlement, command or option names, current events, or particular numbers, dates, or names \u2014 do NOT answer from your own memory: you will confidently make things up (a fake API, a wrong entitlement, an event that did not happen). Route it to `Act`, which can search and verify, and speak only what its report says. DELEGATION RULE \u2014 decide for yourself, the user never has to push: if you cannot answer confidently from the conversation plus trivial well-known knowledge, do NOT refuse and do NOT guess \u2014 dispatch `Act` immediately with a clear brief and say you are checking. Anything needing CURRENT data (weather, news, prices, dates, sky/astronomy, "right now"), real computation, or verification is an automatic dispatch \u2014 never a refusal. The user should never need to say "search the web" or "think harder" to make you act; needing fresh or verified information IS the trigger. Refuse only what your worker genuinely cannot do (check `capabilities`), and say why. Answer inline ONLY for general conversation, chit-chat, and trivia you are sure of, or facts you can see via QuickLook. When elaborating on a completed task ("tell me more", "the gist"), stay strictly within what that result actually said \u2014 if the user asks for something the result did not cover, that is NEW information: dispatch `Act`, do not improvise.\nALWAYS react before you work: the FIRST thing in your turn is a brief spoken acknowledgement of what you heard and what you are about to do ("got it \u2014 opening that now", "sure, let me pull it up", "okay, checking"). NEVER call a tool (Act, Think, QuickLook) silently \u2014 the user must hear you react before you go quiet to work. After dispatching Act or Think, that same one short sentence IS your turn \u2014 end it and do not wait for the result. Exactly ONE short line: never stack a second acknowledgement, and never narrate your own presence or status while waiting ("I\'m here", "let me see", "still checking" right after you already acked). One clean ack then silence reads as competent; repeated check-ins read as nervous.\nA completed task speaks its OWN result to the user (the worker voices what matters as it finishes) \u2014 you do NOT re-voice clean task results. A FAILED or INCOMPLETE task still arrives as a "[task t1 failed] \u2026" event for you to handle. The completed result stays in YOUR context \u2014 it is yours to draw on. When the user follows up ("tell me more", "what else", "and?"), answer FROM that result first: you already have the detail, so elaborate on what you have. Do NOT spawn a fresh worker to re-search or re-gather what you were just handed. Re-dispatch ONLY when genuinely new information is needed \u2014 e.g. the user wants the full contents of a SPECIFIC source, which is one WebFetch of that URL, not a brand-new search. "[task t1 progress] \u2026" events are interim status, NOT results \u2014 on a genuinely LONG wait you MAY give one brief half-sentence aside, but only occasionally; silence while working is normal and fine (the user knows you are on it). Do NOT narrate every step or re-announce yourself. Never present progress as a finished result.\nCRITICAL: while a task is still running you have NO answer yet \u2014 never state a specific result of any kind (a number, size, count, name, path, or value). The real answer exists ONLY once the task finishes (the worker speaks it, and `TaskStatus` then returns it); inventing one meanwhile (a made-up disk size, commit count, etc.) is a serious error. Until then, only acknowledge and wait.\nNever read raw file paths, diffs, or code aloud verbatim.\nDo NOT end every turn with the same canned offer ("want a rundown?", "want the steps?"). Offer once at most; if the user pushes back, repeats themselves, or sounds unsatisfied ("you know what I mean?", "think deeper", "are you sure?"), do NOT re-offer the same thing \u2014 change approach: dispatch `Act`/`Think` to actually dig in, or ask one concrete clarifying question. Repeating a non-answer is worse than silence.\n"[task t1 asks] \u2026" events are QUESTIONS from a background task \u2014 relay to the user in your own words, short, then end your turn. When the user answers, call `AnswerTask` with that id and their answer. NEVER answer on the user\'s behalf for permissions or risky operations; if their reply is ambiguous, confirm first.\nIf the user\'s message sounds INCOMPLETE \u2014 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 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.';
9909
+ 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. 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 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.';
9892
9910
  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.";
9893
9911
  var THINK_DISABLED_GUIDANCE = "(Think tier is not available \u2014 use Act for all escalations.)";
9894
9912
  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).`;
@@ -9967,6 +9985,10 @@ var DuplexAgent = class _DuplexAgent {
9967
9985
  // this turn (or its repair) had a leak cut — the dead-air fallback must not blame the user
9968
9986
  stepMarks = [];
9969
9987
  // reflexBuf offsets where a step ended (its text is complete there)
9988
+ dispatchAt = -1;
9989
+ // reflexBuf offset of this turn's first Act/Think dispatch (-1 = none): text after it is the ack only
9990
+ cutKind;
9991
+ // why claimCut cut the current sentence (diag/log wording)
9970
9992
  leakVerdicts = /* @__PURE__ */ new Map();
9971
9993
  // judged suspect sentences this turn (true = leak)
9972
9994
  judging;
@@ -10026,7 +10048,7 @@ var DuplexAgent = class _DuplexAgent {
10026
10048
  ];
10027
10049
  const workerMcp = mcpNames.length ? `, and it can use these MCP servers: ${[...new Set(mcpNames)].join(", ")}` + (mcpNames.some((n) => /browser/i.test(n)) ? ' \u2014 including driving a REAL browser (open tabs, navigate, click, screenshot), so answer "yes" if asked whether you can control/drive a browser and route an actual browse to Act' : "") : "";
10028
10050
  const prompt = VOICE_SYSTEM_PROMPT.replace("{{MEMORY_SLOT}}", memSlot).replace("{{THINK_SLOT}}", thinkSlot).replace("{{WORKER_WEB}}", workerWeb + workerMcp) + (o.quickLook?.sessions ? `
10029
- Past conversations with this user are saved. When they refer to an EARLIER conversation or session ("last time", "the previous session", "you already did it before", "earlier today"), call QuickLook what:"sessions" (then what:"sessions" with path:"<id>" to read one) BEFORE answering \u2014 never say it does not exist without checking. Report only what the recap says: if it does not say why something happened, say you don't know \u2014 never guess a motive.` : "") + (o.voiceStyle === "conversational" ? "\n" + VOICE_STYLE_CONVERSATIONAL : "") + (o.emotionTags ? "\n" + EMOTION_TAGS_GUIDANCE : "") + `
10051
+ Past conversations with this user are saved. When they refer to an EARLIER conversation or session ("last time", "the previous session", "you already did it before", "earlier today"), call QuickLook what:"sessions" (then what:"sessions" with path:"<id>" to read one) BEFORE answering \u2014 never say it does not exist without checking. Those are only YOUR (agentx) conversations: sessions of other tools (e.g. a Claude Code session) are not there \u2014 dispatch Act to find those. Report only what the recap says: if it does not say why something happened, say you don't know \u2014 never guess a motive.` : "") + (o.voiceStyle === "conversational" ? "\n" + VOICE_STYLE_CONVERSATIONAL : "") + (o.emotionTags ? "\n" + EMOTION_TAGS_GUIDANCE : "") + `
10030
10052
  ${nowLine()}. Anchor every relative or time-sensitive reference \u2014 "today", "now", "current", "recent", "latest", "this year" \u2014 to THIS moment. When the user asks about something recent (news, sports, events), they mean near this date, not a well-known past instance; brief your worker with the current year so it searches for what is happening NOW, not a famous older event.`;
10031
10053
  const tools = [
10032
10054
  ...o.reflexOptions?.tools ?? [],
@@ -10151,7 +10173,8 @@ ${nowLine()}. Anchor every relative or time-sensitive reference \u2014 "today",
10151
10173
  repeat = e;
10152
10174
  break;
10153
10175
  }
10154
- const v = this.leakVerdict(region.slice(ok, e));
10176
+ this.cutKind = void 0;
10177
+ const v = this.claimCut(sent, from + ok, this.turnSpoken + region.slice(0, ok)) || this.leakVerdict(sent);
10155
10178
  if (v === void 0) break;
10156
10179
  if (v) {
10157
10180
  leak = region.slice(ok, e);
@@ -10173,8 +10196,9 @@ ${nowLine()}. Anchor every relative or time-sensitive reference \u2014 "today",
10173
10196
  const at = from + ok;
10174
10197
  this.leakHit = true;
10175
10198
  this.leakCuts.push(leak.trim().slice(0, 40));
10176
- log21.warn(`reflex spoke its own analysis ("${leak.trim().slice(0, 40)}\u2026") \u2014 cut before TTS`);
10177
- this.notify("diag", "analysis_leak", { text: leak.trim().slice(0, 80), at: this.turnSpoken.trim() ? "mid_reply" : "before_speech" });
10199
+ 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";
10200
+ log21.warn(`reflex ${why} ("${leak.trim().slice(0, 40)}\u2026") \u2014 cut before TTS`);
10201
+ 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" });
10178
10202
  const next = this.stepMarks.find((k) => k > at);
10179
10203
  if (next === void 0) {
10180
10204
  this.reflexForwarded = buf.length;
@@ -10225,6 +10249,28 @@ ${nowLine()}. Anchor every relative or time-sensitive reference \u2014 "today",
10225
10249
  return void 0;
10226
10250
  }
10227
10251
  static LEAK_JUDGE_TIMEOUT_MS = 1500;
10252
+ /** Deterministic cuts of a complete reflex sentence at buffer offset `at` (`prior` = this turn's reply before it):
10253
+ * - after this turn's dispatch only ONE ack line may follow, and it may not state an outcome (RESULT_CLAIM_RE) — the
10254
+ * task has only just started;
10255
+ * - while any task runs, a "Done."-style completion claim is false (the worker speaks its own result);
10256
+ * - a second reply glued onto a finished one ("…saved.[neutral] I've scanned…", two harmony finals in one step,
10257
+ * live 20261013-152733 msg 62) restating it is dropped with the rest of its step.
10258
+ * A cut goes through the leak path: muted before TTS, scrubbed from the transcript. */
10259
+ claimCut(sentence, at, prior) {
10260
+ const s = sentence.trim();
10261
+ if (!s) return false;
10262
+ const post = this.dispatchAt >= 0 && at >= this.dispatchAt;
10263
+ const running = [...this.tasks.values()].some((t) => t.status === "running");
10264
+ if (post && (/[\p{L}\p{N}]/u.test(this.reflexBuf.slice(this.dispatchAt, at).replace(/\[[^\]]*\]/g, "")) || RESULT_CLAIM_RE.test(s)) || running && DONE_CLAIM_RE.test(s)) {
10265
+ this.cutKind = "claim";
10266
+ return true;
10267
+ }
10268
+ if (at > 0 && /[.!?…]$/.test(this.reflexBuf.slice(0, at)) && /^\[[^\]]+\]/.test(sentence) && wordOverlap(s, prior) >= 0.3) {
10269
+ this.cutKind = "restart";
10270
+ return true;
10271
+ }
10272
+ return false;
10273
+ }
10228
10274
  /** The stream is done: settle every pending verdict and release everything the gates still hold. */
10229
10275
  async drainReflex() {
10230
10276
  for (; ; ) {
@@ -10306,10 +10352,28 @@ ${nowLine()}. Anchor every relative or time-sensitive reference \u2014 "today",
10306
10352
  this.armHoldResume();
10307
10353
  return;
10308
10354
  }
10309
- if (this.silentTurn && res?.finishReason === "error" && refusedCall(res.error)) this.emitHost({ kind: "text_delta", message: fallback ?? _DuplexAgent.FALLBACK_ERROR });
10355
+ if (this.silentTurn && res?.finishReason === "error" && (refusedCall(res.error) || rateLimited(res.error))) this.emitHost({ kind: "text_delta", message: fallback ?? _DuplexAgent.FALLBACK_ERROR });
10310
10356
  else if (this.silentTurn) await this.ackIfSilent(fallback);
10311
10357
  else if (this.hollowPromise) await this.followThrough();
10312
10358
  }
10359
+ /** One reflex model call for a turn; a turn rejected for load (rateLimited) that has not spoken or dispatched yet is rolled
10360
+ * back and re-run once on `reflexFallbackModel` — a re-prompt of the same model (the dead-air nudge) only hits the
10361
+ * same limit and ends in "could you say that again?". */
10362
+ async reflexSend(content) {
10363
+ const base = this.voice.transcript.length;
10364
+ const res = await this.voice.send(content);
10365
+ const fb = this.options.reflexFallbackModel, model = this.voice.options.model;
10366
+ 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;
10367
+ log21.warn(`reflex ${model} rate-limited \u2014 re-running the turn on ${fb}`);
10368
+ this.notify("diag", "reflex_fallback", { from: model, to: fb });
10369
+ this.voice.transcript.length = base;
10370
+ this.voice.options.model = fb;
10371
+ try {
10372
+ return await this.voice.send(content);
10373
+ } finally {
10374
+ this.voice.options.model = model;
10375
+ }
10376
+ }
10313
10377
  /** The turn SAID it is on it ("got it — checking…") but called no tool and nothing is running: a promise nothing
10314
10378
  * keeps — the user waits on silence (live Para #798: a follow-up while the reflex believed a finished task still ran). */
10315
10379
  get hollowPromise() {
@@ -10318,7 +10382,8 @@ ${nowLine()}. Anchor every relative or time-sensitive reference \u2014 "today",
10318
10382
  if ([...this.tasks.values()].some((t) => t.status === "running")) return false;
10319
10383
  return _DuplexAgent.PROMISE_RE.test(this.turnSpoken);
10320
10384
  }
10321
- static PROMISE_RE = /\b(?:checking|looking (?:into|it up)|let me (?:check|look|see|find|pull)|i(?:'ll| will) (?:check|look|find|pull)|on it|one (?:moment|sec(?:ond)?))\b/i;
10385
+ // "looking for…"/"searching…" too: live 20261013-152733 msg 88 "Got it, looking for any Claude Code session files" — no tool, no task.
10386
+ static PROMISE_RE = /\b(?:checking|searching|digging|looking (?:into|for|through|up|it up)|pulling (?:up|that|it)|let me (?:check|look|see|find|pull|search|dig|grab)|i(?:'ll| will|['’]m going to) (?:check|look|find|pull|search|dig|grab)|on it|one (?:moment|sec(?:ond)?))\b/i;
10322
10387
  static FOLLOW_THROUGH = "[reminder] You told the user you are checking, but no task is running and you called no tool. Keep that promise now: if a finished task result above already answers it, give that answer; otherwise call Act with a clear brief. Do not acknowledge again.";
10323
10388
  /** Re-prompt the reflex ONCE, tools allowed, to keep its hollow promise; the reminder never stays in the transcript. */
10324
10389
  async followThrough() {
@@ -10379,6 +10444,7 @@ ${nowLine()}. Anchor every relative or time-sensitive reference \u2014 "today",
10379
10444
  this.leakVerdicts.clear();
10380
10445
  this.turnSpoken = "";
10381
10446
  this.offerOpen = false;
10447
+ this.dispatchAt = -1;
10382
10448
  this.dropJudge();
10383
10449
  this.turnBase = this.voice.transcript.length;
10384
10450
  this.turnFollowUp = false;
@@ -10461,6 +10527,7 @@ ${nowLine()}. Anchor every relative or time-sensitive reference \u2014 "today",
10461
10527
  this.reflexForwarded = 0;
10462
10528
  this.stepMarks = [];
10463
10529
  this.dropJudge();
10530
+ if (this.dispatchAt >= 0) this.dispatchAt = 0;
10464
10531
  this.nudging = true;
10465
10532
  try {
10466
10533
  await this.voice.send(reminder);
@@ -10665,7 +10732,7 @@ ${nowLine()}. Anchor every relative or time-sensitive reference \u2014 "today",
10665
10732
  }
10666
10733
  let res;
10667
10734
  try {
10668
- res = await this.voice.send(content);
10735
+ res = await this.reflexSend(content);
10669
10736
  } finally {
10670
10737
  if (signal) {
10671
10738
  this.voice.options.signal = ext;
@@ -10961,7 +11028,7 @@ Update: ${brief}`;
10961
11028
  const failed = events.find((e) => /^\[task\b[^\]\n]*\bfailed\b/i.test(e));
10962
11029
  this.resetTurn();
10963
11030
  this.turnFollowUp = nonCleanTurn;
10964
- const res = await this.voice.send(events.join("\n"));
11031
+ const res = await this.reflexSend(events.join("\n"));
10965
11032
  await this.endTurn(res, failed ? "Sorry, that didn't work \u2014 the task failed." : void 0);
10966
11033
  this.notify("revoice_done", "");
10967
11034
  });
@@ -10977,7 +11044,7 @@ Update: ${brief}`;
10977
11044
  buildBrief(brief, tier = "act", deliver = true) {
10978
11045
  const recent = this.recentExcerpt();
10979
11046
  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." : "";
10980
- const deliverContract = deliver ? "\n\n## DELIVER (spoken delivery)\nYou are reporting back to a user who is LISTENING. Stream your work normally \u2014 your prose is the written work record and detail, and is NOT spoken. Wrap anything the user should HEAR in <spoken>\u2026</spoken> tags. LEAD WITH the actual content they asked for: if they asked for a specific piece of content \u2014 a value, a name, the actual lines, the writing itself \u2014 that content goes INSIDE the <spoken> tags, not a remark about it. Your FIRST <spoken> segment is substantive \u2014 never a greeting or an acknowledgement (the front-end has already acked; do not double-ack). Keep spoken text concise and natural for the ear: short sentences, no markdown. " + (this.options.spokenBudgetChars ? `Speak a SHORT summary only: at most three short sentences with the one thing that matters most, then ONE short offer that names what else you have, as a question they can answer in a word \u2014 under ${this.options.spokenBudgetChars} characters in all, the offer included. Everything else stays in your written prose (it is kept and they can ask for it); spoken text past that budget is cut and held until they ask. ` : "") + `NEVER enumerate in speech \u2014 no numbered or bulleted lists ("One. \u2026 Two. \u2026" is robotic). Deliver multiple items as flowing conversation with brief connective phrasing ("here's one\u2026", "and another\u2026", "oh, and\u2026"), pausing between items with sentence breaks, not numbers.` + (this.options.emotionTags ? " Inside <spoken>, you may prefix a sentence with an inline [emotion] tag (e.g. [excited], [curious]) to color how it is voiced \u2014 only when it genuinely fits, and vary it; [laughter] gives a natural laugh." : "") : "";
11047
+ 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." : "") : "";
10981
11048
  return `${nowLine()}.
10982
11049
 
10983
11050
  ` + (recent ? `${brief}
@@ -11328,6 +11395,7 @@ Another agent just implemented the above. Independently check the CURRENT state
11328
11395
  run: async ({ brief, label, refines }) => {
11329
11396
  this.spokeBeforeDispatch = this.spokeThisTurn;
11330
11397
  this.turnDispatched = true;
11398
+ if (this.dispatchAt < 0) this.dispatchAt = this.reflexBuf.length;
11331
11399
  this.turnBriefs.add(String(brief ?? ""));
11332
11400
  this.voice.options.toolChoice = "none";
11333
11401
  const amended = refines ? this.amendTask(String(refines), String(brief ?? "")) : void 0;
@@ -11352,6 +11420,7 @@ Another agent just implemented the above. Independently check the CURRENT state
11352
11420
  run: async ({ brief, label }) => {
11353
11421
  this.spokeBeforeDispatch = this.spokeThisTurn;
11354
11422
  this.turnDispatched = true;
11423
+ if (this.dispatchAt < 0) this.dispatchAt = this.reflexBuf.length;
11355
11424
  this.turnBriefs.add(String(brief ?? ""));
11356
11425
  this.voice.options.toolChoice = "none";
11357
11426
  const id = await this.dispatch(String(brief ?? ""), "think", label ? String(label) : void 0, this.turnFollowUp);
@@ -11385,7 +11454,7 @@ Another agent just implemented the above. Independently check the CURRENT state
11385
11454
  const kinds = [.../* @__PURE__ */ new Set(["time", "branch", "ls", "file", "capabilities", ...Object.keys(this.options.quickLook ?? {})])];
11386
11455
  return {
11387
11456
  name: "QuickLook",
11388
- description: `Instant read-only lookup \u2014 one of: ${kinds.join(", ")}. ` + (kinds.includes("sessions") ? '`sessions` = EARLIER conversations with this user: what:"sessions" lists them; what:"sessions" with path:"<session id>" reads one (not what:"file"). ' : "") + "For trivial facts only; anything needing search, commands, or reasoning goes through Act.",
11457
+ description: `Instant read-only lookup \u2014 one of: ${kinds.join(", ")}. ` + (kinds.includes("sessions") ? '`sessions` = EARLIER agentx conversations with this user (only agentx\'s own \u2014 other tools\' sessions need Act): what:"sessions" lists them; what:"sessions" with path:"<session id>" reads one (not what:"file"). ' : "") + "For trivial facts only; anything needing search, commands, or reasoning goes through Act.",
11389
11458
  parameters: {
11390
11459
  type: "object",
11391
11460
  required: ["what"],
@@ -11406,7 +11475,8 @@ Another agent just implemented the above. Independently check the CURRENT state
11406
11475
  const actTools = this.options.actOptions?.tools ?? [];
11407
11476
  const names = actTools.map((t) => t.name);
11408
11477
  const mcpServers = Object.keys(this.options.actOptions?.providerOptions?.mcpServers ?? {});
11409
- const mcpNote = mcpServers.length ? ` Plus MCP servers your worker can use: ${mcpServers.join(", ")} (e.g. browser-bridge \u2192 drive a real browser: open tabs, navigate, click, screenshot).` : "";
11478
+ const terms = this.options.sttTerms?.() ?? [];
11479
+ const mcpNote = (mcpServers.length ? ` Plus MCP servers your worker can use: ${mcpServers.join(", ")} (e.g. browser-bridge \u2192 drive a real browser: open tabs, navigate, click, screenshot).` : "") + (terms.length ? ` Speech-to-text is primed with these known words (from memory names + the project; refreshed when voice starts): ${terms.join(", ")}.` : "");
11410
11480
  if (!names.length)
11411
11481
  return "Your worker uses Act's default local toolset (reading/editing files, running shell commands). No extra tools (e.g. web/internet) are configured; if a request is not a basic file or shell operation, assume you can't do it and say so." + mcpNote;
11412
11482
  const hasFetch = names.some((n) => /WebFetch/i.test(n));
@@ -13445,6 +13515,11 @@ function bindSecondChannel(v, sc, live) {
13445
13515
  sc.onBargeIn();
13446
13516
  onBargeIn(phase);
13447
13517
  };
13518
+ const speakDelta = v.speakDelta.bind(v);
13519
+ v.speakDelta = ((t, ...rest) => {
13520
+ if (live()) sc.noteReply(t);
13521
+ return speakDelta(t, ...rest);
13522
+ });
13448
13523
  Object.assign(sc.options, {
13449
13524
  aec: v.usingAec,
13450
13525
  speak: (text, kind) => {
@@ -13657,6 +13732,9 @@ var JsonlDiagSink = class {
13657
13732
  // src/voice/secondChannel.ts
13658
13733
  init_logging();
13659
13734
 
13735
+ // src/voice/backchannelPlan.ts
13736
+ init_logging();
13737
+
13660
13738
  // src/voice/channelLang.ts
13661
13739
  init_logging();
13662
13740
  import { mkdir, readFile, writeFile } from "fs/promises";
@@ -13700,6 +13778,24 @@ function isPhatic(text, bank) {
13700
13778
  return !!q2 && (t === q2 || t.startsWith(q2 + " "));
13701
13779
  });
13702
13780
  }
13781
+ 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(" "));
13782
+ 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(" ");
13783
+ var WANT = /^(i (want|need|would like|d like|wanna)|אני (רוצה|צריך|צריכה|מבקש|מבקשת))(\s|$)/u;
13784
+ var lead = (text) => {
13785
+ const w = text.toLowerCase().replace(/[^\p{L}\p{N}\s]+/gu, "").split(/\s+/).filter(Boolean);
13786
+ let i = 0;
13787
+ while (i < w.length && (SKIP.has(w[i]) || /^\d+$/.test(w[i]))) i++;
13788
+ return w.slice(i);
13789
+ };
13790
+ function requestOpener(text) {
13791
+ const w = lead(text);
13792
+ if (!w.length) return false;
13793
+ return ASK.includes(w[0]) || WANT.test(w.slice(0, 4).join(" ")) || new RegExp("^\u05EA\\p{Script=Hebrew}{2,}$", "u").test(w[0]);
13794
+ }
13795
+ function pendingQuestion(text, lastWords = 6) {
13796
+ const t = text.toLowerCase().replace(/[^\p{L}\p{N}'\s]+/gu, " ").split(/\s+/).filter(Boolean).slice(-lastWords).join(" ");
13797
+ 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);
13798
+ }
13703
13799
  var SCRIPTS = [
13704
13800
  [new RegExp("\\p{Script=Hebrew}", "gu"), "he"],
13705
13801
  [new RegExp("\\p{Script=Arabic}", "gu"), "ar"],
@@ -13770,13 +13866,14 @@ function cleanLine(s, lang, maxWords = 6) {
13770
13866
  }
13771
13867
  return l.split(/\s+/).length > maxWords || l.length > 60 ? "" : l;
13772
13868
  }
13869
+ 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;
13773
13870
  function validateBank(raw, lang, minPerKind = 3) {
13774
13871
  if (!raw || typeof raw !== "object") return null;
13775
13872
  const out = {};
13776
13873
  for (const k of BANK_KEYS) {
13777
13874
  const v = raw[k];
13778
13875
  const seen = /* @__PURE__ */ new Set();
13779
- out[k] = (Array.isArray(v) ? v : []).filter((x) => typeof x === "string").map((x) => cleanLine(x, lang)).filter((x) => x && !seen.has(norm2(x)) && seen.add(norm2(x)));
13876
+ out[k] = (Array.isArray(v) ? v : []).filter((x) => typeof x === "string").map((x) => cleanLine(x, lang)).filter((x) => x && !(k === "filler" && SPEAKER_FILLER.test(norm2(x))) && !seen.has(norm2(x)) && seen.add(norm2(x)));
13780
13877
  if (out[k].length < minPerKind) {
13781
13878
  log29.warn(`bank ${lang}: only ${out[k].length} valid ${k} lines \u2014 rejected`);
13782
13879
  return null;
@@ -13891,10 +13988,82 @@ var ChannelBanks = class {
13891
13988
  }
13892
13989
  };
13893
13990
 
13991
+ // src/voice/backchannelPlan.ts
13992
+ var log30 = forComponent("BackchannelPlan");
13993
+ var TURN_TYPES = ["story", "list", "complaint", "explanation", "short_command", "question", "other"];
13994
+ var DENSITIES = ["none", "low", "normal", "high"];
13995
+ var silentTurn = (p) => p.density === "none" || p.turn === "short_command" || p.turn === "question";
13996
+ var cueWords = (s) => s.toLowerCase().replace(/[^\p{L}\p{N}'\s]+/gu, " ").split(/\s+/).filter(Boolean);
13997
+ function parsePlan(raw, lang, maxCues = 8) {
13998
+ const m = raw.match(/\{[\s\S]*\}/);
13999
+ if (!m) return null;
14000
+ let o;
14001
+ try {
14002
+ o = JSON.parse(m[0]);
14003
+ } catch {
14004
+ return null;
14005
+ }
14006
+ const turn = String(o?.turn ?? "").toLowerCase().replace(/[\s-]+/g, "_");
14007
+ const density = String(o?.density ?? "").toLowerCase();
14008
+ if (!TURN_TYPES.includes(turn) || !DENSITIES.includes(density)) return null;
14009
+ const cues = [];
14010
+ for (const c of Array.isArray(o.cues) ? o.cues : []) {
14011
+ const when = (Array.isArray(c?.when) ? c.when : [c?.when]).filter((w) => typeof w === "string").map((w) => cueWords(w).join(" ")).filter((w) => w && w.split(" ").length <= 4);
14012
+ const line = typeof c?.line === "string" ? cleanLine(c.line, lang, 4) : "";
14013
+ if (when.length && line) cues.push({ when: [...new Set(when)], line });
14014
+ if (cues.length >= maxCues) break;
14015
+ }
14016
+ return { turn, density, cues };
14017
+ }
14018
+ function matchCue(plan, heard, from = 0) {
14019
+ if (!plan || silentTurn(plan)) return null;
14020
+ const w = cueWords(heard);
14021
+ for (let i = Math.max(0, from); i < w.length; i++) {
14022
+ for (const c of plan.cues) {
14023
+ for (const p of c.when) {
14024
+ const pw = p.split(" ");
14025
+ if (i + pw.length <= w.length && pw.every((x, j) => w[i + j] === x)) return c;
14026
+ }
14027
+ }
14028
+ }
14029
+ return null;
14030
+ }
14031
+ var BackchannelPlannerOptions = class {
14032
+ /** Fast model (Groq gpt-oss / the reflex). */
14033
+ model = "groq/openai/gpt-oss-120b";
14034
+ maxTokens = 900;
14035
+ };
14036
+ var BackchannelPlanner = class {
14037
+ constructor(ai, options) {
14038
+ this.ai = ai;
14039
+ this.options = options;
14040
+ this.options = { ...new BackchannelPlannerOptions(), ...options };
14041
+ }
14042
+ ai;
14043
+ options;
14044
+ async plan(c, signal) {
14045
+ const o = this.options;
14046
+ const name = languageName(c.lang);
14047
+ const prompt = `You plan a live voice assistant's LISTENER sounds ("mm-hm", "right", "oh no") for the user's ${c.heard ? "CURRENT" : "NEXT"} spoken turn.
14048
+ Assistant just said: "${c.agent.slice(-400)}"
14049
+ ` + (c.user ? `User's previous turn ended: "${c.user.slice(-200)}"
14050
+ ` : "") + (c.heard ? `User so far in this turn: "${c.heard.slice(-300)}"
14051
+ ` : "") + `Predict the turn: turn = story | list | complaint | explanation | short_command | question | other; density = none (short request or a question: never interrupt) | low | normal | high (a long story wants frequent cues).
14052
+ cues: 3-8 short words/phrases the user is likely to say at a clause boundary where a listener cue fits (list markers like "first", "and then", "also"; narrative markers; emotional words; names/topics from the context), each with ONE fitting listener line of 1-3 words in ${name} that reacts to it (sympathy for bad news, "okay" for a list step) \u2014 never a question, never an answer.
14053
+ Reply JSON only: {"turn":"...","density":"...","cues":[{"when":["phrase",...],"line":"..."}]}. Phrases in the user's language.`;
14054
+ const reasoning = /gpt-oss/.test(o.model) ? { providerOptions: { reasoning_effort: "low" } } : {};
14055
+ const r = await this.ai.chat({ model: o.model, messages: [{ role: "user", content: prompt }], stream: false, signal, maxTokens: o.maxTokens, ...reasoning });
14056
+ const p = parsePlan(r.content ?? "", c.lang);
14057
+ if (!p) log30.debug(`unusable plan: ${(r.content ?? "").slice(0, 120)}`);
14058
+ return p;
14059
+ }
14060
+ };
14061
+
13894
14062
  // src/voice/secondChannel.ts
13895
- var log30 = forComponent("SecondChannel");
14063
+ var log31 = forComponent("SecondChannel");
13896
14064
  var SecondChannelOptions = class {
13897
- /** Refiner (Jev/fast LLM): the choice over CHANNEL_ACTIONS, or null on failure. Absent → the rule alone decides. */
14065
+ /** Refiner (Jev/fast LLM): the choice over CHANNEL_ACTIONS, or null on failure. Absent → the rule alone decides.
14066
+ * `state.rule` is the kind the rule proposed (fastBackchannelDecider answers only 'backchannel' moments). */
13898
14067
  decide;
13899
14068
  /** Fast-model line writer (see fastLineGenerator). Absent or late → the canned bank. */
13900
14069
  generate;
@@ -13931,7 +14100,11 @@ var SecondChannelOptions = class {
13931
14100
  avoidLines = 4;
13932
14101
  /** Canned picks exclude the last N picks of the same kind. */
13933
14102
  bankExclude = 2;
13934
- backchannelMinWords = 6;
14103
+ /** A backchannel needs a substantial chunk first: a turn of ≤8 words is a request/answer, not a story (replay: most
14104
+ * misplaced "mm-hm"s fired at words 6-8 of a short command). */
14105
+ backchannelMinWords = 10;
14106
+ /** A turn that opens as a question/command (requestOpener) holds backchannels until it has grown into an explanation. */
14107
+ requestHoldWords = 18;
13935
14108
  /** No new interim for this long mid-utterance (a breath / clause gap, shorter than a pause) → a backchannel moment. */
13936
14109
  backchannelPauseMs = 350;
13937
14110
  /** A bridge waits this long after the user's turn is dispatched, and plays only if channel-1 has said nothing yet
@@ -13941,6 +14114,23 @@ var SecondChannelOptions = class {
13941
14114
  fillerPauseMs = 700;
13942
14115
  fillerMinWords = 4;
13943
14116
  bridgeMinWords = 5;
14117
+ /** ANTICIPATE (backchannelPlan.ts): plan the user's next turn off the hot path (after the agent's reply / at turn start,
14118
+ * revised once at `reviseAtWords`); live, a backchannel fires only on a planned cue, at a `cuePauseMs` breath, with the
14119
+ * cue's line and no decider call. A silent plan (short command / question / density none) vetoes backchannels. No plan
14120
+ * yet / failed / late → the reactive path. Off by default (AGENTX_CHANNEL_ANTICIPATE=1). */
14121
+ anticipate = false;
14122
+ plan;
14123
+ planBudgetMs = 4e3;
14124
+ reviseAtWords = 7;
14125
+ /** A predicted moment needs only a short breath (the decision is already made). */
14126
+ cuePauseMs = 200;
14127
+ /** A cue counts only within the last N words (older matches were gated or passed). */
14128
+ cueWindow = 6;
14129
+ /** true: a plan fires on its cues only. false: a non-silent plan with density normal/high also lets the reactive breath
14130
+ * backchannel through (the plan then only vetoes short commands / questions / low density). */
14131
+ cueOnly = true;
14132
+ onPlan = () => {
14133
+ };
13944
14134
  now = () => performance.now();
13945
14135
  random = Math.random;
13946
14136
  };
@@ -13949,12 +14139,12 @@ function withBudget(p, ms, what) {
13949
14139
  let t;
13950
14140
  const late = new Promise((r) => {
13951
14141
  t = setTimeout(() => {
13952
- log30.debug(`${what}: over the ${ms}ms budget \u2014 dropped`);
14142
+ log31.debug(`${what}: over the ${ms}ms budget \u2014 dropped`);
13953
14143
  r(null);
13954
14144
  }, ms);
13955
14145
  });
13956
14146
  return Promise.race([p.catch((e) => {
13957
- log30.warn(`${what} failed \u2014 falling back: ${e?.message ?? e}`);
14147
+ log31.warn(`${what} failed \u2014 falling back: ${e?.message ?? e}`);
13958
14148
  return null;
13959
14149
  }), late]).finally(() => clearTimeout(t));
13960
14150
  }
@@ -13991,6 +14181,18 @@ var SecondChannel = class {
13991
14181
  startedAt;
13992
14182
  lang = null;
13993
14183
  pinned = false;
14184
+ // anticipate: the plan for the user's current/next turn (turnNo bumps per dispatched turn → a late plan is dropped)
14185
+ turnNo = 0;
14186
+ plan = null;
14187
+ planning = false;
14188
+ planTries = 0;
14189
+ // per turn: the reply-end plan + one turn-start retry, never a call per interim
14190
+ revised = false;
14191
+ cueFrom = 0;
14192
+ // word index after the last planned backchannel
14193
+ reply = "";
14194
+ // channel-1's text this turn (the plan's context)
14195
+ lastUser = "";
13994
14196
  /** The language lines are spoken in (detected from the user's words, or set by the host). */
13995
14197
  get language() {
13996
14198
  return this.lang;
@@ -14002,7 +14204,7 @@ var SecondChannel = class {
14002
14204
  if (host) this.pinned = true;
14003
14205
  const lang = primaryLang(tag);
14004
14206
  if (!lang || lang === this.lang) return;
14005
- log30.debug(`language ${this.lang} \u2192 ${lang}`);
14207
+ log31.debug(`language ${this.lang} \u2192 ${lang}`);
14006
14208
  this.lang = lang;
14007
14209
  this.recent = [];
14008
14210
  this.picks.clear();
@@ -14034,11 +14236,21 @@ var SecondChannel = class {
14034
14236
  this.pauseTimer = null;
14035
14237
  this.onPause(this.o.fillerPauseMs);
14036
14238
  }, this.o.fillerPauseMs);
14239
+ let cue = null;
14240
+ if (this.o.anticipate) {
14241
+ const n = words2(t).length;
14242
+ if (!this.plan && !this.planning && this.planTries < 2) this.requestPlan();
14243
+ else if (this.plan && !this.revised && n >= this.o.reviseAtWords) {
14244
+ this.revised = true;
14245
+ this.requestPlan(t);
14246
+ }
14247
+ cue = matchCue(this.plan, t, Math.max(this.cueFrom, n - this.o.cueWindow));
14248
+ }
14037
14249
  if (this.bcTimer) clearTimeout(this.bcTimer);
14038
14250
  this.bcTimer = this.o.aec ? setTimeout(() => {
14039
14251
  this.bcTimer = null;
14040
- if (this.userSpeaking && this.heard === t) void this.consider("interim", 0, true);
14041
- }, this.o.backchannelPauseMs) : null;
14252
+ if (this.userSpeaking && this.heard === t) void this.consider("interim", 0, true, cue);
14253
+ }, cue ? this.o.cuePauseMs : this.o.backchannelPauseMs) : null;
14042
14254
  if (words2(t).length - this.decidedAt >= this.o.decideEveryWords) void this.consider("interim");
14043
14255
  }
14044
14256
  /** The user went quiet for `ms` (host VAD/STT pause, or the internal interim timer). */
@@ -14067,9 +14279,17 @@ var SecondChannel = class {
14067
14279
  this.heard = "";
14068
14280
  this.decidedAt = 0;
14069
14281
  this.bridged = false;
14282
+ this.cueFrom = 0;
14283
+ if (this.o.anticipate && this.reply.trim() && !this.plan && !this.planning) this.requestPlan();
14070
14284
  return;
14071
14285
  }
14072
14286
  if (s === "queued") {
14287
+ this.turnNo++;
14288
+ this.plan = null;
14289
+ this.planning = false;
14290
+ this.planTries = 0;
14291
+ this.revised = false;
14292
+ this.reply = "";
14073
14293
  this.gen++;
14074
14294
  this.userSpeaking = false;
14075
14295
  this.bridged = false;
@@ -14088,14 +14308,20 @@ var SecondChannel = class {
14088
14308
  /** The user's final line was dispatched to the main agent (its words are the bridge's micro-context). */
14089
14309
  onUserTurn(text) {
14090
14310
  this.heard = text.trim();
14311
+ this.lastUser = this.heard;
14091
14312
  this.detect(this.heard);
14092
14313
  this.main = "idle";
14093
14314
  this.onAgentState("queued");
14094
14315
  }
14095
14316
  /** The main answer's streamed text (before its audio): channel-1 is responding — no bridge from here on. */
14096
14317
  onMainTokens(text) {
14318
+ this.noteReply(text);
14097
14319
  if (text.trim() && (this.main === "queued" || this.main === "thinking")) this.onAgentState("streaming_text");
14098
14320
  }
14321
+ /** Channel-1's reply text (a delta) — only the plan's context; no state change (hosts without onMainTokens). */
14322
+ noteReply(text) {
14323
+ this.reply += text;
14324
+ }
14099
14325
  /** The user barged in: cut the in-flight line now and drop every pending decision. */
14100
14326
  onBargeIn() {
14101
14327
  this.gen++;
@@ -14105,7 +14331,7 @@ var SecondChannel = class {
14105
14331
  try {
14106
14332
  this.o.cancel();
14107
14333
  } catch (e) {
14108
- log30.warn(`cancel threw: ${e.message}`);
14334
+ log31.warn(`cancel threw: ${e.message}`);
14109
14335
  }
14110
14336
  }
14111
14337
  }
@@ -14134,48 +14360,70 @@ var SecondChannel = class {
14134
14360
  return (this.main === "queued" || this.main === "thinking") && !this.bridged;
14135
14361
  }
14136
14362
  /** 0ms rule: the candidate line for this trigger, or silence. */
14137
- rule(trigger, pauseMs = 0, breath = false) {
14363
+ rule(trigger, pauseMs = 0, breath = false, cue = null) {
14138
14364
  const w = words2(this.heard);
14139
14365
  if (!this.lang) return "silence";
14140
14366
  if (w.length <= 3 || w.length <= 6 && isPhatic(this.heard, this.bank)) return "silence";
14141
- const k = trigger === "interim" ? this.backchannelMoment(w.length, breath) ? "backchannel" : null : trigger === "pause" ? pauseMs >= this.o.fillerPauseMs && w.length >= this.o.fillerMinWords ? "filler" : null : w.length >= this.o.bridgeMinWords ? "bridge" : null;
14367
+ 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;
14142
14368
  return k && this.allowed(k) ? k : "silence";
14143
14369
  }
14370
+ /** Plan (or, with `heard`, revise) the backchannels of the user's turn — async, never on the hot path. */
14371
+ requestPlan(heard) {
14372
+ if (!this.o.plan || !this.lang) return;
14373
+ const turn = this.turnNo, t0 = this.o.now(), ac = new AbortController();
14374
+ if (!heard) {
14375
+ this.planning = true;
14376
+ this.planTries++;
14377
+ }
14378
+ const ctx = { agent: this.reply.trim(), user: words2(this.lastUser).slice(-30).join(" "), recent: [...this.recent], lang: this.lang, heard };
14379
+ void withBudget(this.o.plan(ctx, ac.signal), this.o.planBudgetMs, heard ? "plan revision" : "plan").then((p) => {
14380
+ if (p === null) ac.abort();
14381
+ if (turn !== this.turnNo) return;
14382
+ if (!heard) this.planning = false;
14383
+ if (!p) return;
14384
+ this.plan = p;
14385
+ this.o.onPlan(p, !!heard, Math.round(this.o.now() - t0));
14386
+ });
14387
+ }
14144
14388
  /** A clause boundary mid-floor: a breath or a comma-like close. Not a question (it wants the answer, not "mm-hm"), and a
14145
14389
  * sentence end only deep into a story — a short request's end is the end of their turn (replay: "mm-hm" right after
14146
14390
  * "Check hacker news for recent news.", just before the final). */
14147
14391
  backchannelMoment(n, breath) {
14148
- if (n < this.o.backchannelMinWords || /\?$/.test(this.heard)) return false;
14392
+ if (n < this.o.backchannelMinWords || /\?$/.test(this.heard) || pendingQuestion(this.heard)) return false;
14393
+ if (n < this.o.requestHoldWords && requestOpener(this.heard)) return false;
14149
14394
  if (/[.!]$/.test(this.heard)) return n >= 2 * this.o.backchannelMinWords;
14150
14395
  return breath || /[,;:…]$/.test(this.heard);
14151
14396
  }
14152
- async consider(trigger, pauseMs, breath = false) {
14397
+ async consider(trigger, pauseMs, breath = false, cue = null) {
14153
14398
  if (this.busy) return;
14154
14399
  const n0 = words2(this.heard).length;
14155
14400
  this.decidedAt = n0;
14156
- const rule = this.rule(trigger, pauseMs, breath);
14401
+ if (!(this.o.anticipate && this.plan && trigger === "interim")) cue = null;
14402
+ const rule = this.rule(trigger, pauseMs, breath, cue);
14157
14403
  if (rule === "silence") return;
14158
14404
  this.busy = true;
14159
14405
  const gen = this.gen, t0 = this.o.now();
14160
- const ev = (e) => this.o.onEvent({ trigger, rule, ms: Math.round(this.o.now() - t0), ...e });
14406
+ const ev = (e) => this.o.onEvent({ trigger, rule, ms: Math.round(this.o.now() - t0), ...cue ? { cue: cue.line } : {}, ...e });
14161
14407
  const stale = () => gen !== this.gen || words2(this.heard).length - n0 >= this.o.staleWords;
14162
14408
  try {
14163
14409
  let kind = rule;
14164
14410
  let choice;
14165
- if (this.o.decide) {
14166
- const d = await withBudget(this.o.decide(this.tail(), this.state(pauseMs)), this.o.decisionBudgetMs, "channel decision");
14411
+ if (this.o.decide && !cue) {
14412
+ const d = await withBudget(this.o.decide(this.tail(), { ...this.state(pauseMs), rule }), this.o.decisionBudgetMs, "channel decision");
14167
14413
  choice = d?.choice;
14168
14414
  if (choice && choice in CHANNEL_ACTIONS && (choice === "silence" || this.allowed(choice))) kind = choice;
14169
- else if (choice && choice in CHANNEL_ACTIONS) log30.debug(`decider chose ${choice} for a ${rule} moment \u2014 the rule stands`);
14415
+ else if (choice && choice in CHANNEL_ACTIONS) log31.debug(`decider chose ${choice} for a ${rule} moment \u2014 the rule stands`);
14170
14416
  }
14171
14417
  if (kind === "silence") return ev({ choice, acted: "silence" });
14172
14418
  if (stale()) return ev({ choice, kind, acted: "stale" });
14173
14419
  if (!this.allowed(kind)) return ev({ choice, kind, acted: "gated" });
14174
- const text = await this.line(kind);
14420
+ const text = cue && !this.recent.some((r) => norm2(r) === norm2(cue.line)) ? cue.line : await this.line(kind);
14175
14421
  if (!text) return ev({ choice, kind, acted: "no_bank" });
14176
14422
  if (stale()) return ev({ choice, kind, text, acted: "stale" });
14177
14423
  if (!this.allowed(kind)) return ev({ choice, kind, text, acted: "gated" });
14178
- ev({ choice, kind, text, acted: this.say(kind, text) ? "spoken" : "skipped" });
14424
+ const said = this.say(kind, text);
14425
+ if (said && cue) this.cueFrom = words2(this.heard).length;
14426
+ ev({ choice, kind, text, acted: said ? "spoken" : "skipped" });
14179
14427
  } finally {
14180
14428
  this.busy = false;
14181
14429
  }
@@ -14199,7 +14447,7 @@ var SecondChannel = class {
14199
14447
  if (raw === null) ac.abort();
14200
14448
  const text = cleanLine(raw ?? "", this.lang);
14201
14449
  if (text && !this.recent.some((r) => norm2(r) === norm2(text))) return text;
14202
- if (text) log30.debug(`generated "${text}" repeats a recent line \u2014 canned`);
14450
+ if (text) log31.debug(`generated "${text}" repeats a recent line \u2014 canned`);
14203
14451
  }
14204
14452
  return this.pick(kind);
14205
14453
  }
@@ -14221,7 +14469,7 @@ var SecondChannel = class {
14221
14469
  try {
14222
14470
  r = this.o.speak(text, kind);
14223
14471
  } catch (e) {
14224
- log30.warn(`speak threw \u2014 line dropped: ${e.message}`);
14472
+ log31.warn(`speak threw \u2014 line dropped: ${e.message}`);
14225
14473
  return false;
14226
14474
  }
14227
14475
  if (r === false) return false;
@@ -14233,7 +14481,7 @@ var SecondChannel = class {
14233
14481
  this.o.show(text, kind);
14234
14482
  const p = Promise.resolve(r).then(() => {
14235
14483
  }, (e) => {
14236
- log30.warn(`channel line playback failed: ${e?.message ?? e}`);
14484
+ log31.warn(`channel line playback failed: ${e?.message ?? e}`);
14237
14485
  }).finally(() => {
14238
14486
  if (this.inflight === p) this.inflight = null;
14239
14487
  });
@@ -14241,6 +14489,23 @@ var SecondChannel = class {
14241
14489
  return true;
14242
14490
  }
14243
14491
  };
14492
+ function fastBackchannelDecider(ai, model = "groq/qwen/qwen3.8-27b") {
14493
+ return async (heard, state) => {
14494
+ if (state.rule !== "backchannel") return null;
14495
+ const prompt = `Live voice call. The user is still talking and just took a breath. So far: "${heard}"
14496
+ 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. Answer yes or no.`;
14497
+ const reasoning = /qwen/.test(model) ? { providerOptions: { reasoning_effort: "none" } } : /gpt-oss/.test(model) ? { providerOptions: { reasoning_effort: "low" } } : {};
14498
+ const r = await ai.chat({ model, messages: [{ role: "user", content: prompt }], stream: false, maxTokens: /gpt-oss/.test(model) ? 200 : 3, ...reasoning });
14499
+ const a = (r.content ?? "").trim().toLowerCase();
14500
+ return /^yes/.test(a) ? { choice: "backchannel" } : /^no/.test(a) ? { choice: "silence" } : null;
14501
+ };
14502
+ }
14503
+ function channelDecide(ai, instinct, env = process.env) {
14504
+ const model = env.AGENTX_CHANNEL_DECIDER_MODEL || void 0;
14505
+ const bc = model || env.GROQ_API_KEY ? fastBackchannelDecider(ai, model) : void 0;
14506
+ if (!bc && !instinct) return void 0;
14507
+ return async (heard, state) => bc && state.rule === "backchannel" ? bc(heard, state) : instinct ? instinct.channel(heard, state) : null;
14508
+ }
14244
14509
  var KIND_HINT2 = {
14245
14510
  backchannel: 'a tiny listener cue while they keep talking ("mm-hm", "right", "yeah") \u2014 shows you follow, never takes the turn',
14246
14511
  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',
@@ -14405,7 +14670,7 @@ function applyOverlay(cfg, file) {
14405
14670
  // cli/hooks-config.ts
14406
14671
  import { spawnSync as spawnSync4 } from "child_process";
14407
14672
  init_platform();
14408
- var log31 = forComponent("hooks");
14673
+ var log32 = forComponent("hooks");
14409
14674
  var escapeRegex = (s) => s.replace(/[.+^${}()|[\]\\]/g, "\\$&");
14410
14675
  function ruleMatches(rule, toolName) {
14411
14676
  if (!rule.tool || rule.tool === "*") return true;
@@ -14424,7 +14689,7 @@ function runCmd(rule, env) {
14424
14689
  });
14425
14690
  return { code: r.status ?? 1, out: ((r.stdout ?? "") + (r.stderr ?? "")).trim() };
14426
14691
  } catch (e) {
14427
- log31.debug(`hook command failed: ${rule.command}`, e);
14692
+ log32.debug(`hook command failed: ${rule.command}`, e);
14428
14693
  return { code: 1, out: String(e?.message ?? e) };
14429
14694
  }
14430
14695
  }
@@ -14532,7 +14797,7 @@ import { existsSync as existsSync6, mkdirSync as mkdirSync8, readFileSync as rea
14532
14797
  import { homedir as homedir6 } from "os";
14533
14798
  import { join as join11 } from "path";
14534
14799
  init_platform();
14535
- var log32 = forComponent("session");
14800
+ var log33 = forComponent("session");
14536
14801
  var globalDir = () => join11(homedir6(), ".agent", "sessions");
14537
14802
  function indexLink(target, link2) {
14538
14803
  try {
@@ -14595,7 +14860,7 @@ var SessionStore = class {
14595
14860
  }
14596
14861
  load(id) {
14597
14862
  if (!this.safeId(id)) {
14598
- log32.debug(`rejecting unsafe session id: ${id}`);
14863
+ log33.debug(`rejecting unsafe session id: ${id}`);
14599
14864
  return void 0;
14600
14865
  }
14601
14866
  const path = join11(this.dir, `${id}.json`);
@@ -14603,7 +14868,7 @@ var SessionStore = class {
14603
14868
  try {
14604
14869
  return JSON.parse(readFileSync8(path, "utf8"));
14605
14870
  } catch (e) {
14606
- log32.debug(`unreadable session ${id} \u2014 ignoring`, e);
14871
+ log33.debug(`unreadable session ${id} \u2014 ignoring`, e);
14607
14872
  return void 0;
14608
14873
  }
14609
14874
  }
@@ -14616,7 +14881,7 @@ var SessionStore = class {
14616
14881
  try {
14617
14882
  metas.push(JSON.parse(readFileSync8(join11(this.dir, f), "utf8")).meta);
14618
14883
  } catch (e) {
14619
- log32.debug(`skipping unreadable session file ${f}`, e);
14884
+ log33.debug(`skipping unreadable session file ${f}`, e);
14620
14885
  }
14621
14886
  }
14622
14887
  return metas.sort((a, b) => b.updated - a.updated);
@@ -14725,7 +14990,7 @@ function sessionRecap(store, currentId, which, cap = 2500, maxParseBytes = 4e6)
14725
14990
  const asks = (data2?.messages ?? []).filter((m) => m.role === "user").map(recapLine).filter(Boolean).slice(0, 3).map((l) => `"${l.slice(6, 86)}"`);
14726
14991
  return `- ${f.id} (${when(created(f.id))}${data2 ? `, ${data2.meta.turns} turns` : ""})${asks.length ? `: ${asks.join("; ")}` : ""}`;
14727
14992
  });
14728
- return `Earlier sessions (newest first) \u2014 to read one, call QuickLook with what:"sessions" and path:"<id>":
14993
+ return `Earlier agentx sessions in this project (newest first; only agentx's own \u2014 other tools' sessions, e.g. Claude Code's, are not here: dispatch Act for those) \u2014 to read one, call QuickLook with what:"sessions" and path:"<id>":
14729
14994
  ${rows.join("\n")}`;
14730
14995
  }
14731
14996
  const recent = others.slice(0, 5).map((f) => f.id).join(", ");
@@ -14741,7 +15006,7 @@ ${rows.join("\n")}`;
14741
15006
  hit = pre[0];
14742
15007
  }
14743
15008
  }
14744
- if (!hit) return `No session '${w}'. Recent: ${recent}`;
15009
+ if (!hit) return `No session '${w}'. Recent: ${recent}` + (/^\d{8}-\d{6}/.test(w) ? "" : ` (That id is not an agentx session id \u2014 this lookup covers only agentx's own sessions. A session of another tool, e.g. Claude Code, is not here: dispatch Act to find it.)`);
14745
15010
  const data = load(hit);
14746
15011
  if (!data) return `Session ${hit.id} is too large (or unreadable) to recap here \u2014 ask a worker (Act) to read it.`;
14747
15012
  let body = data.messages.map(recapLine).filter(Boolean).map((l) => l.length > 600 ? `${l.slice(0, 600)}\u2026` : l).join("\n");
@@ -14855,7 +15120,7 @@ import { promisify } from "util";
14855
15120
  import { writeFileSync as writeFileSync6, mkdirSync as mkdirSync9 } from "fs";
14856
15121
  import { homedir as homedir7 } from "os";
14857
15122
  import { join as join12, resolve as resolve4, sep as sep3, parse as parsePath } from "path";
14858
- var log33 = forComponent("checkpoints");
15123
+ var log34 = forComponent("checkpoints");
14859
15124
  var exec = promisify(execFile4);
14860
15125
  var DEFAULT_EXCLUDE = [".agent/", ".git/", "node_modules/", "dist/", "build/", ".next/", "target/", ".venv/", "__pycache__/", "*.log"];
14861
15126
  function isBroadRoot(p) {
@@ -14901,7 +15166,7 @@ var ShadowRepo = class {
14901
15166
  writeFileSync6(join12(this.gitDir, "info", "exclude"), this.exclude.join("\n") + "\n");
14902
15167
  this.ready = true;
14903
15168
  } catch (e) {
14904
- log33.debug(`git checkpoints unavailable for ${this.workTree}`, e);
15169
+ log34.debug(`git checkpoints unavailable for ${this.workTree}`, e);
14905
15170
  this.ready = false;
14906
15171
  }
14907
15172
  return this.ready;
@@ -14912,7 +15177,7 @@ var ShadowRepo = class {
14912
15177
  }
14913
15178
  async commit(label, forced = []) {
14914
15179
  await this.run("add", "-A");
14915
- for (const p of forced) await this.run("add", "-f", "--", p).catch((e) => log33.debug(`force-add failed: ${p}`, e));
15180
+ for (const p of forced) await this.run("add", "-f", "--", p).catch((e) => log34.debug(`force-add failed: ${p}`, e));
14916
15181
  await this.run("commit", "--allow-empty", "-q", "-m", label);
14917
15182
  }
14918
15183
  /** Inject the CURRENT (pre-edit) content of `paths` into the turn-open restore point by amending it.
@@ -14920,8 +15185,8 @@ var ShadowRepo = class {
14920
15185
  * turn-boundary `add -A` would never have captured it. Amend (vs a new commit) keeps one restore
14921
15186
  * point per turn, so the REPL's turn↔frame mapping stays intact. */
14922
15187
  async amendForced(paths) {
14923
- for (const p of paths) await this.run("add", "-f", "--", p).catch((e) => log33.debug(`force-capture failed: ${p}`, e));
14924
- await this.run("commit", "--amend", "--no-edit", "-q", "--allow-empty").catch((e) => log33.debug("amend failed", e));
15188
+ for (const p of paths) await this.run("add", "-f", "--", p).catch((e) => log34.debug(`force-capture failed: ${p}`, e));
15189
+ await this.run("commit", "--amend", "--no-edit", "-q", "--allow-empty").catch((e) => log34.debug("amend failed", e));
14925
15190
  }
14926
15191
  /** Commits on `ref`, oldest-first (canonical index space). */
14927
15192
  async log(ref) {
@@ -14981,7 +15246,7 @@ var ShadowRepo = class {
14981
15246
  await this.run("gc", "--auto", "-q").catch(() => {
14982
15247
  });
14983
15248
  } catch (e) {
14984
- log33.debug("checkpoint prune failed", e);
15249
+ log34.debug("checkpoint prune failed", e);
14985
15250
  }
14986
15251
  }
14987
15252
  };
@@ -15049,7 +15314,7 @@ var GitCheckpoints = class {
15049
15314
  use(sessionId) {
15050
15315
  if (sessionId === this.session) return;
15051
15316
  this.session = sessionId;
15052
- if (this.started) for (const r of this.repos) void r.point(this.ref()).catch((e) => log33.debug("re-point failed", e));
15317
+ if (this.started) for (const r of this.repos) void r.point(this.ref()).catch((e) => log34.debug("re-point failed", e));
15053
15318
  }
15054
15319
  async begin(label) {
15055
15320
  if (!await this.start()) return;
@@ -15069,7 +15334,7 @@ var GitCheckpoints = class {
15069
15334
  process.stderr.write(`\x1B[2m checkpoints: '${this.repos[i].root}' too large to snapshot in ${Math.round(this.options.opTimeoutMs / 1e3)}s \u2014 file rewind disabled here\x1B[0m
15070
15335
  `);
15071
15336
  } else {
15072
- log33.debug("checkpoint commit failed", e);
15337
+ log34.debug("checkpoint commit failed", e);
15073
15338
  survivors.push(this.repos[i]);
15074
15339
  survivorCaches.push(this.caches[i] ?? []);
15075
15340
  }
@@ -15104,7 +15369,7 @@ var GitCheckpoints = class {
15104
15369
  if (this.forced.has(abs)) continue;
15105
15370
  this.forced.add(abs);
15106
15371
  const i = this.repoIndexFor(abs);
15107
- if (i >= 0) await this.repos[i].amendForced([abs]).catch((e) => log33.debug("amendForced failed", e));
15372
+ if (i >= 0) await this.repos[i].amendForced([abs]).catch((e) => log34.debug("amendForced failed", e));
15108
15373
  }
15109
15374
  }
15110
15375
  };
@@ -16889,7 +17154,7 @@ import { spawnSync as spawnSync6 } from "child_process";
16889
17154
  import { writeFileSync as writeFileSync8, mkdirSync as mkdirSync10, readdirSync as readdirSync2, unlinkSync as unlinkSync3, chmodSync, existsSync as existsSync7 } from "fs";
16890
17155
  import { homedir as homedir8 } from "os";
16891
17156
  import { join as join14 } from "path";
16892
- var log34 = forComponent("os-sched");
17157
+ var log35 = forComponent("os-sched");
16893
17158
  var OsScheduler = class {
16894
17159
  options;
16895
17160
  constructor(options) {
@@ -16951,7 +17216,7 @@ var OsScheduler = class {
16951
17216
  }
16952
17217
  }
16953
17218
  } catch (e) {
16954
- log34.debug(`cancel ${id}`, e);
17219
+ log35.debug(`cancel ${id}`, e);
16955
17220
  }
16956
17221
  for (const f of [`${id}.json`, `${id}.sh`]) {
16957
17222
  try {
@@ -17069,7 +17334,7 @@ import { existsSync as existsSync8, mkdirSync as mkdirSync11, unlinkSync as unli
17069
17334
  import { homedir as homedir9 } from "os";
17070
17335
  import { join as join15 } from "path";
17071
17336
  init_platform();
17072
- var log35 = forComponent("remote-trigger");
17337
+ var log36 = forComponent("remote-trigger");
17073
17338
  var TRIGGER_DIR = () => join15(homedir9(), ".agent", "triggers");
17074
17339
  var sockPath = (sessionId, dir = TRIGGER_DIR()) => join15(dir, `${sessionId}.sock`);
17075
17340
  var TriggerServer = class {
@@ -17107,13 +17372,13 @@ var TriggerServer = class {
17107
17372
  conn.end(JSON.stringify({ ok: false, error: String(e) }) + "\n");
17108
17373
  }
17109
17374
  });
17110
- conn.on("error", (e) => log35.debug("trigger conn error", e));
17375
+ conn.on("error", (e) => log36.debug("trigger conn error", e));
17111
17376
  });
17112
- this.server.on("error", (e) => log35.debug("trigger server error", e));
17377
+ this.server.on("error", (e) => log36.debug("trigger server error", e));
17113
17378
  this.server.listen(p);
17114
17379
  this.path = p;
17115
17380
  } catch (e) {
17116
- log35.debug("trigger server unavailable", e);
17381
+ log36.debug("trigger server unavailable", e);
17117
17382
  }
17118
17383
  }
17119
17384
  /** Re-bind on /resume (the session id changed). */
@@ -17245,7 +17510,7 @@ var strike = C("9");
17245
17510
  var link = (text, url) => useColor ? `\x1B]8;;${url}\x1B\\${cyan(text)}\x1B]8;;\x1B\\` : `${text} (${url})`;
17246
17511
  var mdPalette = { bold, dim, cyan, italic, strike, green, link };
17247
17512
  var err = (s) => process.stderr.write(s);
17248
- var log36 = forComponent("cli");
17513
+ var log37 = forComponent("cli");
17249
17514
  var VERSION = (() => {
17250
17515
  try {
17251
17516
  return JSON.parse(readFileSync10(new URL("../package.json", import.meta.url), "utf8")).version ?? "?";
@@ -18088,7 +18353,7 @@ function turnCost(model, usage) {
18088
18353
  const priced = model.startsWith("claude-code/") ? `anthropic/${resolveModelAlias(model.slice("claude-code/".length))}` : model;
18089
18354
  return costOf(getModelInfo(priced)?.pricing, usage?.promptTokens ?? 0, usage?.completionTokens ?? 0, usage?.cacheCreationTokens ?? 0, usage?.cacheReadTokens ?? 0, priced);
18090
18355
  }
18091
- async function evaluateGoal(ai, condition, transcript, log37) {
18356
+ async function evaluateGoal(ai, condition, transcript, log38) {
18092
18357
  const recent = transcript.filter((m) => m.role === "assistant").slice(-8).map((m) => {
18093
18358
  const text = typeof m.content === "string" ? m.content : m.content.filter((p) => p.type === "text").map((p) => p.text).join(" ");
18094
18359
  return text.slice(0, 600);
@@ -18108,7 +18373,7 @@ ${recent}` }
18108
18373
  const match = r.content.match(/\{[\s\S]*\}/);
18109
18374
  if (match) return JSON.parse(match[0]);
18110
18375
  } catch (e) {
18111
- log37(dim(` (goal evaluator error: ${e?.message ?? e})
18376
+ log38(dim(` (goal evaluator error: ${e?.message ?? e})
18112
18377
  `));
18113
18378
  }
18114
18379
  return { met: false, reason: "evaluation unclear" };
@@ -18334,7 +18599,7 @@ function mcpAgentTools(mounted, opts) {
18334
18599
  return tools;
18335
18600
  }
18336
18601
  async function closeMcp(mounted) {
18337
- await Promise.all(mounted.map((m) => m.client.close().catch((e) => log36.debug("mcp close failed", e))));
18602
+ await Promise.all(mounted.map((m) => m.client.close().catch((e) => log37.debug("mcp close failed", e))));
18338
18603
  }
18339
18604
  var IMG_EXT = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp" };
18340
18605
  function mentionRefs(line) {
@@ -18393,7 +18658,7 @@ async function expandMentions(fs, line) {
18393
18658
  if (loaded.includes(ref) || missing.includes(ref)) continue;
18394
18659
  if (ref.includes(":") && mcpMentionResolver) {
18395
18660
  const body = await mcpMentionResolver(ref).catch((e) => {
18396
- log36.debug("mcp mention resolve failed", e);
18661
+ log37.debug("mcp mention resolve failed", e);
18397
18662
  return null;
18398
18663
  });
18399
18664
  if (body != null) {
@@ -18484,7 +18749,7 @@ async function runTurn(agent, store, session, task, cp, cwd = process.cwd(), sen
18484
18749
  try {
18485
18750
  store.save(session);
18486
18751
  } catch (ex) {
18487
- log36.debug("mid-turn session flush failed", ex);
18752
+ log37.debug("mid-turn session flush failed", ex);
18488
18753
  }
18489
18754
  }
18490
18755
  return origNotify(e);
@@ -18648,14 +18913,14 @@ var isCancelTeardown = (e) => {
18648
18913
  function installCancelGuards(mounted) {
18649
18914
  process.on("unhandledRejection", (e) => {
18650
18915
  if (isCancelTeardown(e)) {
18651
- log36.debug("suppressed unhandledRejection (cursor stream cancel)", e);
18916
+ log37.debug("suppressed unhandledRejection (cursor stream cancel)", e);
18652
18917
  return;
18653
18918
  }
18654
- log36.error("unhandledRejection", e);
18919
+ log37.error("unhandledRejection", e);
18655
18920
  });
18656
18921
  process.on("uncaughtException", (e) => {
18657
18922
  if (isCancelTeardown(e)) {
18658
- log36.debug("suppressed uncaughtException (cursor stream cancel)", e);
18923
+ log37.debug("suppressed uncaughtException (cursor stream cancel)", e);
18659
18924
  return;
18660
18925
  }
18661
18926
  console.error(e);
@@ -18715,6 +18980,7 @@ async function repl(args, ai, cfg, cwd) {
18715
18980
  let dx;
18716
18981
  let voiceIO;
18717
18982
  let voiceDiag;
18983
+ let liveSttTerms = [];
18718
18984
  const decider = duplex ? leakDecider() : void 0;
18719
18985
  const instinct = decider && process.env.VOICE_INSTINCT === "1" ? new Instinct({
18720
18986
  decider,
@@ -18966,6 +19232,7 @@ async function repl(args, ai, cfg, cwd) {
18966
19232
  // Cross-session recall: earlier conversations in this project (path = a session id / "previous").
18967
19233
  sessions: (which) => sessionRecap(store, session.meta.id, which)
18968
19234
  },
19235
+ sttTerms: () => liveSttTerms,
18969
19236
  // The voice runs on the REAL fs (it has no fs tools — harmless) so @mentions, !cmd and #note
18970
19237
  // resolve against the project; + CC-parity chrome for its own tool calls (⚙ Act …).
18971
19238
  reflexOptions: { fs: agent.options.fs, hooks: displayHooks(agent.options.fs), tools: [rewindFilesTool, exitSessionTool(() => {
@@ -19082,7 +19349,7 @@ async function repl(args, ai, cfg, cwd) {
19082
19349
  try {
19083
19350
  mkdirSync12(dir, { recursive: true });
19084
19351
  } catch (e) {
19085
- log36.debug("paste image dir create failed", e);
19352
+ log37.debug("paste image dir create failed", e);
19086
19353
  }
19087
19354
  const img = grabClipboardImage(dir, String(Date.now()));
19088
19355
  return img ? { display: "Image", ref: "@" + img.path, path: img.path } : null;
@@ -19195,7 +19462,7 @@ Added entries are loadable now via the Skill/SlashCommand tools; removed ones ar
19195
19462
  mkdirSync12(join16(cwd, ".agent"), { recursive: true });
19196
19463
  appendFileSync(histPath, line + "\n");
19197
19464
  } catch (e) {
19198
- log36.debug("history write failed", e);
19465
+ log37.debug("history write failed", e);
19199
19466
  }
19200
19467
  };
19201
19468
  const ago = (t) => {
@@ -19267,7 +19534,7 @@ Added entries are loadable now via the Skill/SlashCommand tools; removed ones ar
19267
19534
  try {
19268
19535
  store.save(session);
19269
19536
  } catch (e) {
19270
- log36.debug("session save after rewind failed", e);
19537
+ log37.debug("session save after rewind failed", e);
19271
19538
  }
19272
19539
  err(green(" \u27F2 jumped back") + dim(` \u2014 ${face.transcript.length} message(s) kept; edit + resend
19273
19540
  `));
@@ -19302,7 +19569,7 @@ ${task}`;
19302
19569
  bangContext.length = 0;
19303
19570
  }
19304
19571
  const delta = await refreshCatalogs().catch((e) => {
19305
- log36.debug("catalog refresh failed", e);
19572
+ log37.debug("catalog refresh failed", e);
19306
19573
  return "";
19307
19574
  });
19308
19575
  if (delta) {
@@ -19565,7 +19832,7 @@ ${task}`;
19565
19832
  desc: "rescan skills/commands dirs and rebuild the system prompt (one cache miss) \u2014 picks up entries created mid-session",
19566
19833
  run: async () => {
19567
19834
  await refreshCatalogs().catch((e) => {
19568
- log36.debug("catalog refresh failed", e);
19835
+ log37.debug("catalog refresh failed", e);
19569
19836
  });
19570
19837
  face.reprepare();
19571
19838
  err(green(` \u2713 reloaded \u2014 ${skills.length} skill(s), ${cmds.length} command(s); system prompt rebuilds on next message
@@ -20068,7 +20335,7 @@ ${task}`;
20068
20335
  try {
20069
20336
  for (const def of (await loadAgents(fs2, d)).agents) if (!seen.has(def.name)) seen.set(def.name, { def, from: d });
20070
20337
  } catch (e) {
20071
- log36.debug(`loadAgents(${d}) failed`, e);
20338
+ log37.debug(`loadAgents(${d}) failed`, e);
20072
20339
  }
20073
20340
  }
20074
20341
  if (!seen.size) {
@@ -20156,7 +20423,7 @@ ${task}`;
20156
20423
  }
20157
20424
  if (idx >= 0) {
20158
20425
  const old = mounted.splice(idx, 1)[0];
20159
- await old.client.close().catch((e) => log36.debug("mcp close failed", e));
20426
+ await old.client.close().catch((e) => log37.debug("mcp close failed", e));
20160
20427
  }
20161
20428
  try {
20162
20429
  const m = await mountMcpServer(name, conf);
@@ -20184,7 +20451,7 @@ ${task}`;
20184
20451
  }
20185
20452
  const m = mounted.splice(idx, 1)[0];
20186
20453
  remountMcpTools();
20187
- await m.client.close().catch((e) => log36.debug("mcp close failed", e));
20454
+ await m.client.close().catch((e) => log37.debug("mcp close failed", e));
20188
20455
  err(dim(` removed "${name}"
20189
20456
  `));
20190
20457
  return;
@@ -20387,7 +20654,7 @@ ${task}`;
20387
20654
  try {
20388
20655
  return readdirSync4(join16(cwd, absDir.replace(/^\/+/, "")), { withFileTypes: true }).map((d) => ({ name: d.name, dir: d.isDirectory() }));
20389
20656
  } catch (e) {
20390
- log36.debug("completion readdir failed", absDir, e);
20657
+ log37.debug("completion readdir failed", absDir, e);
20391
20658
  return null;
20392
20659
  }
20393
20660
  };
@@ -20524,7 +20791,7 @@ ${out}
20524
20791
  return;
20525
20792
  }
20526
20793
  await refreshCatalogs().catch((e) => {
20527
- log36.debug("catalog refresh failed", e);
20794
+ log37.debug("catalog refresh failed", e);
20528
20795
  });
20529
20796
  const sk = skills.find((x) => x.name === name);
20530
20797
  if (sk) {
@@ -20576,6 +20843,7 @@ ${out}
20576
20843
  `));
20577
20844
  return [];
20578
20845
  }) : [basename4(agent.options.fs?.getCwd() ?? process.cwd())];
20846
+ liveSttTerms = sttTerms;
20579
20847
  voiceIO = new VoiceIO({
20580
20848
  ...fakeVoice ?? {},
20581
20849
  sttTerms,
@@ -20642,7 +20910,7 @@ ${out}
20642
20910
  if (delivery || cut) dx?.parkCutDelivery(delivery, !!cut, heard);
20643
20911
  const cutReply = cut && cut.full.length - cut.heard.length > 40 ? cut : null;
20644
20912
  const noteP = replaces ? replaces.note : !cutReply ? Promise.resolve("") : (instinct ? instinct.bargeIntent(text).catch((e) => {
20645
- log36.warn(`barge intent failed \u2014 model decides: ${e.message}`);
20913
+ log37.warn(`barge intent failed \u2014 model decides: ${e.message}`);
20646
20914
  return void 0;
20647
20915
  }) : Promise.resolve(void 0)).then(async (intent) => interruptionNote(cutReply, intent) + ackedNote(await instinct?.ackFor(text)));
20648
20916
  const cutIn = replaces ? replaces.cutIn : interjectNote;
@@ -21004,12 +21272,15 @@ function makeSecondChannel(ai, reflexModel, opts) {
21004
21272
  const decider = leakDecider();
21005
21273
  const instinct = decider ? new Instinct({ decider }) : void 0;
21006
21274
  const model = process.env.AGENTX_CHANNEL_MODEL || reflexModel;
21275
+ const planner = new BackchannelPlanner(ai, { model });
21007
21276
  return new SecondChannel({
21008
21277
  // Banks in the user's language: generated once per language, cached + topped up across sessions (~/.agent/cache).
21009
21278
  banks: new ChannelBanks({ generate: fastBankGenerator(ai, model), cacheDir: join16(homedir10(), ".agent", "cache", "channel-banks") }),
21010
- decide: instinct ? (heard, state) => instinct.channel(heard, state) : void 0,
21279
+ decide: channelDecide(ai, instinct),
21011
21280
  generate: fastLineGenerator(ai, model),
21012
- onEvent: (e) => log36.debug(`channel ${e.trigger}: rule=${e.rule} choice=${e.choice ?? "-"} \u2192 ${e.acted}${e.text ? ` "${e.text}"` : ""} ${e.ms}ms`),
21281
+ // Anticipated backchannels (off until the offline replay proves them — tests/voice-bc-anticipate-eval.ts)
21282
+ ...process.env.AGENTX_CHANNEL_ANTICIPATE === "1" ? { anticipate: true, plan: (c, s) => planner.plan(c, s) } : {},
21283
+ onEvent: (e) => log37.debug(`channel ${e.trigger}: rule=${e.rule} choice=${e.choice ?? "-"} \u2192 ${e.acted}${e.text ? ` "${e.text}"` : ""}${e.cue ? " (planned cue)" : ""} ${e.ms}ms`),
21013
21284
  ...opts
21014
21285
  });
21015
21286
  }
@@ -21071,7 +21342,7 @@ async function duplexServe(args, ai, cfg, cwd) {
21071
21342
  try {
21072
21343
  store.save(session);
21073
21344
  } catch (e) {
21074
- log36.warn("duplex session save failed", e);
21345
+ log37.warn("duplex session save failed", e);
21075
21346
  }
21076
21347
  };
21077
21348
  out({ type: "ready", sessionId: session.meta.id, reflexModel: dx.options.reflexModel, actModel: dx.options.actModel, ...notes.length ? { notes } : {} });
@@ -21109,7 +21380,7 @@ async function duplexServe(args, ai, cfg, cwd) {
21109
21380
  done2();
21110
21381
  out({ type: "turn_done", id, finishReason: res?.finishReason ?? "error", ...why ? { error: why } : {} });
21111
21382
  }).catch((e) => {
21112
- log36.error("duplex turn failed", e);
21383
+ log37.error("duplex turn failed", e);
21113
21384
  done2();
21114
21385
  out({ type: "turn_done", id, finishReason: "error", error: String(e?.message ?? e) });
21115
21386
  });