@livx.cc/agentx 0.99.85 → 0.99.86

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
@@ -709,16 +709,16 @@ ${entries.map((s) => " " + s).join("\n")}`);
709
709
  return blocks.length ? blocks.join("\n") : `(no ${label} found)`;
710
710
  }
711
711
  function fuzzyLineReplace(content, oldStr, newStr) {
712
- const norm2 = (s) => s.trim();
712
+ const norm3 = (s) => s.trim();
713
713
  const cl = content.split("\n");
714
- const ol = oldStr.split("\n").map(norm2);
714
+ const ol = oldStr.split("\n").map(norm3);
715
715
  while (ol.length && ol[ol.length - 1] === "") ol.pop();
716
716
  while (ol.length && ol[0] === "") ol.shift();
717
717
  if (!ol.length) return null;
718
718
  const matches = [];
719
719
  for (let i2 = 0; i2 + ol.length <= cl.length; i2++) {
720
720
  let ok = true;
721
- for (let j = 0; j < ol.length; j++) if (norm2(cl[i2 + j]) !== ol[j]) {
721
+ for (let j = 0; j < ol.length; j++) if (norm3(cl[i2 + j]) !== ol[j]) {
722
722
  ok = false;
723
723
  break;
724
724
  }
@@ -6046,14 +6046,14 @@ function summarize(messages, focus) {
6046
6046
  if (lastAssistant) lines.push(`last assistant: ${lastAssistant}`);
6047
6047
  if (carried.length) lines.push("earlier recap:", ...carried.map((l) => ` ${l}`));
6048
6048
  if (focus?.trim()) {
6049
- const words2 = focus.toLowerCase().split(/\s+/).filter((w) => w.length > 2);
6049
+ const words3 = focus.toLowerCase().split(/\s+/).filter((w) => w.length > 2);
6050
6050
  const kept = [];
6051
6051
  const ordered = [...messages.filter((m) => !isSyntheticSummary(m)), ...messages.filter(isSyntheticSummary)];
6052
6052
  outer: for (const msg of ordered) {
6053
6053
  if (msg.role === "tool") continue;
6054
6054
  for (const line of contentText(msg.content).split("\n")) {
6055
6055
  const l = line.toLowerCase();
6056
- if (line.trim() && words2.some((w) => l.includes(w))) {
6056
+ if (line.trim() && words3.some((w) => l.includes(w))) {
6057
6057
  kept.push(line.trim().slice(0, 200));
6058
6058
  if (kept.length >= 12) break outer;
6059
6059
  }
@@ -7101,13 +7101,13 @@ function isTranscriptComplete(text) {
7101
7101
  const trimmed = text.trim();
7102
7102
  if (!trimmed) return false;
7103
7103
  if (/[.?!]$/.test(trimmed)) return true;
7104
- const words2 = trimmed.toLowerCase().split(/\s+/);
7105
- const lastWord = words2[words2.length - 1];
7104
+ const words3 = trimmed.toLowerCase().split(/\s+/);
7105
+ const lastWord = words3[words3.length - 1];
7106
7106
  if (COMPLETE_ENDINGS.has(lastWord)) return true;
7107
7107
  if (INCOMPLETE_ENDINGS.has(lastWord)) return false;
7108
7108
  const lower = trimmed.toLowerCase();
7109
7109
  for (const marker of CONTINUATION_MARKERS) if (lower.endsWith(marker)) return false;
7110
- if (words2.length <= 2) return false;
7110
+ if (words3.length <= 2) return false;
7111
7111
  return true;
7112
7112
  }
7113
7113
  var MAX_HOLD_MS = 2e3;
@@ -7508,13 +7508,13 @@ var VoiceEngine = class _VoiceEngine {
7508
7508
  };
7509
7509
  if (this.options.revealMode === "word" || this.options.heardTimestamps) {
7510
7510
  this.tts.wantTimestamps = true;
7511
- this.tts.onTimestamps = (words2, start) => {
7511
+ this.tts.onTimestamps = (words3, start) => {
7512
7512
  if (this.interrupted) return;
7513
7513
  start.forEach((s, i) => {
7514
- if (this.wordAnchorPending) this.wordPending.push([words2[i] ?? "", s]);
7514
+ if (this.wordAnchorPending) this.wordPending.push([words3[i] ?? "", s]);
7515
7515
  else {
7516
7516
  this.wordStarts.push(this.wordOrigin + s);
7517
- this.wordTexts.push(words2[i] ?? "");
7517
+ this.wordTexts.push(words3[i] ?? "");
7518
7518
  }
7519
7519
  });
7520
7520
  };
@@ -7662,9 +7662,9 @@ var VoiceEngine = class _VoiceEngine {
7662
7662
  this.ackProbe = null;
7663
7663
  this.adaptiveAckFired = false;
7664
7664
  const lead = m ? m[1] : bare;
7665
- const norm2 = lead.replace(/[^a-z ]/gi, " ").replace(/\s+/g, " ").trim().toLowerCase();
7665
+ const norm3 = lead.replace(/[^a-z ]/gi, " ").replace(/\s+/g, " ").trim().toLowerCase();
7666
7666
  const same = this.ackPickedFired ? bare.match(_VoiceEngine.leadingPhrase(this.lastAck)) : null;
7667
- if (_VoiceEngine.ACK_LEADING.test(norm2)) {
7667
+ if (_VoiceEngine.ACK_LEADING.test(norm3)) {
7668
7668
  const rest = m ? m[2] : "";
7669
7669
  this.diag("reflex_ack_suppressed", { lead: lead.trim() });
7670
7670
  if (!rest.trim()) return "";
@@ -7750,8 +7750,8 @@ var VoiceEngine = class _VoiceEngine {
7750
7750
  while (n < this.wordStarts.length && this.wordStarts[n] <= playedSec) n++;
7751
7751
  if (n !== this.revealedN) {
7752
7752
  this.revealedN = n;
7753
- const words2 = this.revealText.trim().split(/\s+/).filter(Boolean);
7754
- this.options.onReveal(words2.slice(0, n).join(" "));
7753
+ const words3 = this.revealText.trim().split(/\s+/).filter(Boolean);
7754
+ this.options.onReveal(words3.slice(0, n).join(" "));
7755
7755
  }
7756
7756
  this.revealPoll = this.clock.setTimeout(tick, 60);
7757
7757
  };
@@ -7778,8 +7778,8 @@ var VoiceEngine = class _VoiceEngine {
7778
7778
  this.adaptiveAckFired = false;
7779
7779
  this.ackProbe = null;
7780
7780
  if (heldProbe) {
7781
- const norm2 = heldProbe.replace(/\[emotion[^\]]*\]/gi, "").replace(/[^a-z ]/gi, " ").replace(/\s+/g, " ").trim().toLowerCase();
7782
- if (_VoiceEngine.ACK_LEADING.test(norm2)) this.diag("reflex_ack_suppressed", { lead: heldProbe.trim() });
7781
+ const norm3 = heldProbe.replace(/\[emotion[^\]]*\]/gi, "").replace(/[^a-z ]/gi, " ").replace(/\s+/g, " ").trim().toLowerCase();
7782
+ if (_VoiceEngine.ACK_LEADING.test(norm3)) this.diag("reflex_ack_suppressed", { lead: heldProbe.trim() });
7783
7783
  else this.speakDelta(heldProbe);
7784
7784
  }
7785
7785
  if (!this.speaking) return;
@@ -8317,14 +8317,14 @@ var VoiceEngine = class _VoiceEngine {
8317
8317
  if (!prev) return next;
8318
8318
  if (!next) return prev;
8319
8319
  const pw = prev.split(/\s+/), nw = next.split(/\s+/);
8320
- const norm2 = _VoiceEngine.normWord;
8320
+ const norm3 = _VoiceEngine.normWord;
8321
8321
  if (pw.length >= 2 && pw.length <= nw.length) {
8322
8322
  let pfx = true;
8323
- for (let i = 0; i < pw.length - 1; i++) if (norm2(pw[i]) !== norm2(nw[i])) {
8323
+ for (let i = 0; i < pw.length - 1; i++) if (norm3(pw[i]) !== norm3(nw[i])) {
8324
8324
  pfx = false;
8325
8325
  break;
8326
8326
  }
8327
- if (pfx && norm2(nw[pw.length - 1]).startsWith(norm2(pw[pw.length - 1]))) return next;
8327
+ if (pfx && norm3(nw[pw.length - 1]).startsWith(norm3(pw[pw.length - 1]))) return next;
8328
8328
  }
8329
8329
  if (_VoiceEngine.restates(prev, next)) {
8330
8330
  this.diag("merge_restated", { dropped: prev.slice(0, 60) });
@@ -8333,7 +8333,7 @@ var VoiceEngine = class _VoiceEngine {
8333
8333
  const max = Math.min(pw.length, nw.length);
8334
8334
  for (let k = max; k > 0; k--) {
8335
8335
  let match = true;
8336
- for (let i = 0; i < k; i++) if (norm2(pw[pw.length - k + i]) !== norm2(nw[i])) {
8336
+ for (let i = 0; i < k; i++) if (norm3(pw[pw.length - k + i]) !== norm3(nw[i])) {
8337
8337
  match = false;
8338
8338
  break;
8339
8339
  }
@@ -8385,11 +8385,11 @@ var VoiceEngine = class _VoiceEngine {
8385
8385
  * race), never worse. Fillers ("mhm") deliberately don't count as replies — see speakFiller. */
8386
8386
  refinalizes(next) {
8387
8387
  const pw = this.lastDispatchWords, nw = next.trim().split(/\s+/);
8388
- const norm2 = _VoiceEngine.normWord;
8388
+ const norm3 = _VoiceEngine.normWord;
8389
8389
  if (pw.length < 3 || nw.length < pw.length) return false;
8390
8390
  if (this.clock.now() - this.lastDispatchAt >= _VoiceEngine.DUP_FINAL_MS) return false;
8391
- for (let i = 0; i < pw.length - 1; i++) if (norm2(pw[i]) !== norm2(nw[i])) return false;
8392
- const pLast = norm2(pw[pw.length - 1]), nSame = norm2(nw[pw.length - 1]);
8391
+ for (let i = 0; i < pw.length - 1; i++) if (norm3(pw[i]) !== norm3(nw[i])) return false;
8392
+ const pLast = norm3(pw[pw.length - 1]), nSame = norm3(nw[pw.length - 1]);
8393
8393
  if (!nSame.startsWith(pLast)) return false;
8394
8394
  return nw.length > pw.length || nSame.length > pLast.length;
8395
8395
  }
@@ -9285,6 +9285,17 @@ var ACK_BANK = {
9285
9285
  steer: ["Ah, okay, switching\u2014", "Ah, got it, so\u2014", "Oh, gotcha, sure\u2014"],
9286
9286
  resume: ["Right, so\u2014", "Anyway, so\u2014", "So, as I was saying\u2014"]
9287
9287
  };
9288
+ var CHANNEL_ACTIONS = {
9289
+ silence: 'the default \u2014 nothing to add. ALWAYS for a greeting, thanks, a yes/no, or any short complete turn the real reply will answer instantly ("hey", "how are you?", "thanks"); also mid-word or mid-clause',
9290
+ backchannel: 'they are mid-way through a longer story or explanation (several clauses) and just closed a clause: a tiny "mm-hm"/"right" shows you follow, without taking the turn',
9291
+ filler: 'they paused mid-thought, searching for words, and a soft "hmm" keeps the line warm',
9292
+ bridge: 'they finished a substantial request or hard question and the real answer takes a moment: a short "let me check\u2014" lead-in'
9293
+ };
9294
+ var CHANNEL_BANK = {
9295
+ backchannel: ["Mm-hm.", "Right.", "Mm, yeah.", "Uh-huh.", "I see.", "Mm."],
9296
+ filler: ["Hmm\u2026", "Mm\u2026", "Hm, mm\u2026", "Ahh\u2026"],
9297
+ bridge: ["Let me check\u2014", "One sec, looking\u2014", "Hm, let me see\u2014", "Okay, give me a moment\u2014", "Let me think on that\u2014"]
9298
+ };
9288
9299
  var INTENT_ACK_OVER = {
9289
9300
  stop: Object.keys(ACK_SITUATIONS).filter((s) => s !== "silent"),
9290
9301
  steer: ["command"],
@@ -9502,6 +9513,17 @@ var Instinct = class {
9502
9513
  if (pick?.text !== text.trim()) return null;
9503
9514
  return Promise.race([pick.p.then((p) => p ? pick.spoken : null), pick.spoken]);
9504
9515
  }
9516
+ /** Duplex v2 second channel: which (if any) listener line fits THIS instant — one Choice over CHANNEL_ACTIONS.
9517
+ * `heard` is the micro-context tail only (never history). Never rejects: a failed call → null (the rule decides). */
9518
+ channel(heard, state) {
9519
+ return this.ask(
9520
+ "channel",
9521
+ heard,
9522
+ { heard_so_far: heard, ...state },
9523
+ FRAME + "You are the quick side-voice of the conversation, NOT the one who answers. Pick the tiny sound a person would make right now, or none. Silence is the default: most moments call for nothing.",
9524
+ CHANNEL_ACTIONS
9525
+ );
9526
+ }
9505
9527
  /** A random phrase from `pool`, never the one said last (no back-to-back repeat across keys). */
9506
9528
  rotate(pool) {
9507
9529
  const fresh = pool.filter((x) => x !== this.lastAckSaid);
@@ -13022,8 +13044,8 @@ var CutInAbsorber = class {
13022
13044
  const cut = this.cut || (meta?.supersedes !== void 0 && meta.supersedes === this.absorbed?.text ? this.absorbed.cut : "");
13023
13045
  this.cut = "";
13024
13046
  this.absorbed = void 0;
13025
- const words2 = (s) => s.trim().split(/\s+/).filter(Boolean).length;
13026
- if (!cut || words2(text) - words2(cut) >= 3) return false;
13047
+ const words3 = (s) => s.trim().split(/\s+/).filter(Boolean).length;
13048
+ if (!cut || words3(text) - words3(cut) >= 3) return false;
13027
13049
  this.absorbed = { text, cut };
13028
13050
  return true;
13029
13051
  }
@@ -13082,6 +13104,77 @@ function bindAckInstinct(v, instinct) {
13082
13104
  onDiag(ev);
13083
13105
  };
13084
13106
  }
13107
+ function bindSecondChannel(v, sc, live) {
13108
+ const o = v.options;
13109
+ const { onPartial, onUtterance, onState, onBargeIn, onBackchannel } = o;
13110
+ const { show } = sc.options;
13111
+ let own = false;
13112
+ let blip = "";
13113
+ o.onBackchannel = (p) => {
13114
+ blip = p;
13115
+ onBackchannel(p);
13116
+ };
13117
+ const take = v.takeInterruptedReply.bind(v);
13118
+ v.takeInterruptedReply = () => {
13119
+ const r = take();
13120
+ if (!r || !sc.spoke(r.full)) return r;
13121
+ return r.queued.length ? { ...r, full: "", heard: "" } : null;
13122
+ };
13123
+ o.onPartial = (t) => {
13124
+ if (live() && !v.audible) sc.onInterim(t);
13125
+ onPartial(t);
13126
+ };
13127
+ o.onUtterance = ((t, meta) => {
13128
+ if (live() && t.trim()) sc.onUserTurn(t);
13129
+ onUtterance(t, meta);
13130
+ });
13131
+ o.onState = (s) => {
13132
+ if (live() && !own) {
13133
+ if (s === "speaking") sc.onAgentState("first_audio");
13134
+ else if (s === "listening") sc.onAgentState("idle");
13135
+ }
13136
+ onState(s);
13137
+ };
13138
+ o.onBargeIn = (phase) => {
13139
+ sc.onBargeIn();
13140
+ onBargeIn(phase);
13141
+ };
13142
+ Object.assign(sc.options, {
13143
+ aec: v.usingAec,
13144
+ speak: (text, kind) => {
13145
+ if (!live() || v.audible) return false;
13146
+ if (kind === "backchannel") {
13147
+ blip = "";
13148
+ v.fireBackchannel();
13149
+ return;
13150
+ }
13151
+ own = true;
13152
+ v.speakFiller(text);
13153
+ return v.awaitIdle().finally(() => {
13154
+ own = false;
13155
+ });
13156
+ },
13157
+ show: (text, kind) => show(kind === "backchannel" && blip ? blip : text, kind)
13158
+ });
13159
+ }
13160
+ function channelInput(sc, m) {
13161
+ if (m.type === "interim") {
13162
+ if (typeof m.aec === "boolean") sc.options.aec = m.aec;
13163
+ if (typeof m.text === "string") sc.onInterim(m.text);
13164
+ return true;
13165
+ }
13166
+ if (m.type === "pause") {
13167
+ sc.onPause(Number(m.ms) || 0);
13168
+ return true;
13169
+ }
13170
+ if (m.type === "agent_state") {
13171
+ if (typeof m.state === "string" && MAIN_STATES.has(m.state)) sc.onAgentState(m.state);
13172
+ else log27.warn(`agent_state: unknown state ${JSON.stringify(m.state)} \u2014 ignored`);
13173
+ return true;
13174
+ }
13175
+ return false;
13176
+ }
13177
+ var MAIN_STATES = /* @__PURE__ */ new Set(["idle", "queued", "thinking", "streaming_text", "first_audio", "speaking"]);
13085
13178
  var VoiceIOOptions = class extends VoiceEngineOptions {
13086
13179
  sonioxApiKey = process.env.SONIOX_API_KEY ?? "";
13087
13180
  cartesiaApiKey = process.env.CARTESIA_API_KEY ?? "";
@@ -13251,6 +13344,316 @@ var JsonlDiagSink = class {
13251
13344
  }
13252
13345
  };
13253
13346
 
13347
+ // src/voice/secondChannel.ts
13348
+ init_logging();
13349
+ var log29 = forComponent("SecondChannel");
13350
+ var SecondChannelOptions = class {
13351
+ /** Refiner (Jev/fast LLM): the choice over CHANNEL_ACTIONS, or null on failure. Absent → the rule alone decides. */
13352
+ decide;
13353
+ /** Fast-model line writer (see fastLineGenerator). Absent or late → the canned bank. */
13354
+ generate;
13355
+ /** Kinds worth generating (content-conditioned); the rest are canned sounds. */
13356
+ generateFor = ["backchannel", "bridge"];
13357
+ /** Host TTS on the second channel; resolves when the line finished playing. `false` = the host could not play it. */
13358
+ speak = () => {
13359
+ };
13360
+ /** Host UI (caption / muted line) — never history. */
13361
+ show = () => {
13362
+ };
13363
+ /** Stop the in-flight line's audio now (barge-in only). */
13364
+ cancel = () => {
13365
+ };
13366
+ onEvent = () => {
13367
+ };
13368
+ /** Echo cancellation on: backchannels may play while the user speaks. Off (half-duplex) → fillers/bridges in silences only. */
13369
+ aec = false;
13370
+ decideEveryWords = 3;
13371
+ decisionBudgetMs = 300;
13372
+ generateBudgetMs = 500;
13373
+ minGapMs = 2500;
13374
+ /** Backchannel after backchannel needs this gap (v1 Instinct's cadence, ≤12/min). */
13375
+ backchannelGapMs = 5e3;
13376
+ /** A decision whose partial grew by this many words while in flight is stale → dropped. */
13377
+ staleWords = 3;
13378
+ microWords = 30;
13379
+ avoidLines = 4;
13380
+ /** Canned picks exclude the last N picks of the same kind. */
13381
+ bankExclude = 2;
13382
+ backchannelMinWords = 8;
13383
+ /** No new interim for this long → a pause (hosts may also call onPause). */
13384
+ fillerPauseMs = 700;
13385
+ fillerMinWords = 4;
13386
+ bridgeMinWords = 5;
13387
+ now = () => performance.now();
13388
+ random = Math.random;
13389
+ };
13390
+ var words2 = (s) => s.trim() ? s.trim().split(/\s+/) : [];
13391
+ var QUICK = /^(hey|hi|hello|yo|hiya|morning|good (morning|evening|afternoon)|thanks|thank you|ok(ay)?|yes|yeah|yep|no|nope|sure|bye|goodbye|how are you|what'?s up|can you hear me)\b/i;
13392
+ function withBudget(p, ms, what) {
13393
+ let t;
13394
+ const late = new Promise((r) => {
13395
+ t = setTimeout(() => {
13396
+ log29.debug(`${what}: over the ${ms}ms budget \u2014 dropped`);
13397
+ r(null);
13398
+ }, ms);
13399
+ });
13400
+ return Promise.race([p.catch((e) => {
13401
+ log29.warn(`${what} failed \u2014 falling back: ${e?.message ?? e}`);
13402
+ return null;
13403
+ }), late]).finally(() => clearTimeout(t));
13404
+ }
13405
+ var SecondChannel = class {
13406
+ constructor(options) {
13407
+ this.options = options;
13408
+ this.options = { ...new SecondChannelOptions(), ...options };
13409
+ this.startedAt = this.o.now();
13410
+ }
13411
+ options;
13412
+ heard = "";
13413
+ userSpeaking = false;
13414
+ main = "idle";
13415
+ lead = "";
13416
+ gen = 0;
13417
+ // bumped by barge-in / a new user turn → in-flight decisions drop
13418
+ decidedAt = 0;
13419
+ // word count at the last decision
13420
+ busy = false;
13421
+ pauseTimer = null;
13422
+ lastAt = -Infinity;
13423
+ lastKind = null;
13424
+ live = null;
13425
+ // our last line, until channel-1 audio takes the floor
13426
+ cutLine = null;
13427
+ // the line the last barge actually cut (spoke())
13428
+ bridged = false;
13429
+ // one bridge per main turn
13430
+ recent = [];
13431
+ picks = /* @__PURE__ */ new Map();
13432
+ inflight = null;
13433
+ startedAt;
13434
+ get o() {
13435
+ return this.options;
13436
+ }
13437
+ /** The user's live partial for the current turn (the whole partial, not a delta). */
13438
+ onInterim(text) {
13439
+ const t = text.trim();
13440
+ if (!t || t === this.heard) return;
13441
+ if (this.main !== "idle") this.onAgentState("idle");
13442
+ this.heard = t;
13443
+ this.userSpeaking = true;
13444
+ if (this.pauseTimer) clearTimeout(this.pauseTimer);
13445
+ this.pauseTimer = setTimeout(() => {
13446
+ this.pauseTimer = null;
13447
+ this.onPause(this.o.fillerPauseMs);
13448
+ }, this.o.fillerPauseMs);
13449
+ if (words2(t).length - this.decidedAt >= this.o.decideEveryWords) void this.consider("interim");
13450
+ }
13451
+ /** The user went quiet for `ms` (host VAD/STT pause, or the internal interim timer). */
13452
+ onPause(ms) {
13453
+ if (this.pauseTimer) {
13454
+ clearTimeout(this.pauseTimer);
13455
+ this.pauseTimer = null;
13456
+ }
13457
+ this.userSpeaking = false;
13458
+ void this.consider("pause", ms);
13459
+ }
13460
+ /** Channel-1 lifecycle. `queued` = the user's turn was dispatched to the main agent. */
13461
+ onAgentState(s) {
13462
+ if (s === this.main) return;
13463
+ this.main = s;
13464
+ if (s === "first_audio" || s === "speaking") this.live = null;
13465
+ if (s === "idle") {
13466
+ this.heard = "";
13467
+ this.decidedAt = 0;
13468
+ this.lead = "";
13469
+ this.bridged = false;
13470
+ return;
13471
+ }
13472
+ if (s === "queued") {
13473
+ this.gen++;
13474
+ this.userSpeaking = false;
13475
+ this.lead = "";
13476
+ this.bridged = false;
13477
+ if (this.pauseTimer) clearTimeout(this.pauseTimer);
13478
+ this.pauseTimer = null;
13479
+ }
13480
+ if (s === "queued" || s === "thinking" || s === "streaming_text") void this.consider("working");
13481
+ }
13482
+ /** The user's final line was dispatched to the main agent (its words are the bridge's micro-context). */
13483
+ onUserTurn(text) {
13484
+ this.heard = text.trim();
13485
+ this.main = "idle";
13486
+ this.onAgentState("queued");
13487
+ }
13488
+ /** The main answer's streamed text (before its audio): a bridge can lead into it. */
13489
+ onMainTokens(text) {
13490
+ if (this.lead.length < 120) this.lead += text;
13491
+ if (this.main === "queued" || this.main === "thinking") this.onAgentState("streaming_text");
13492
+ }
13493
+ /** The user barged in: cut the in-flight line now and drop every pending decision. */
13494
+ onBargeIn() {
13495
+ this.gen++;
13496
+ this.cutLine = this.inflight || this.live ? this.live : null;
13497
+ if (this.inflight) {
13498
+ this.inflight = null;
13499
+ try {
13500
+ this.o.cancel();
13501
+ } catch (e) {
13502
+ log29.warn(`cancel threw: ${e.message}`);
13503
+ }
13504
+ }
13505
+ }
13506
+ /** Is `text` the line the last barge-in cut (ours, still holding the floor)? Hosts strip it from the cut reply — it is
13507
+ * not the agent's speech. By identity, not by text: a real reply that merely repeats a recent line is kept. Consumed once. */
13508
+ spoke(text) {
13509
+ const c = this.cutLine;
13510
+ this.cutLine = null;
13511
+ return !!c && c === text.trim();
13512
+ }
13513
+ /** Resolves when the in-flight line finished playing (immediately when none) — hosts enqueue channel-1 audio behind it. */
13514
+ drain() {
13515
+ return this.inflight ?? Promise.resolve();
13516
+ }
13517
+ get audioLocked() {
13518
+ return this.main === "first_audio" || this.main === "speaking";
13519
+ }
13520
+ get working() {
13521
+ return this.main === "queued" || this.main === "thinking" || this.main === "streaming_text";
13522
+ }
13523
+ tail() {
13524
+ return words2(this.heard).slice(-this.o.microWords).join(" ");
13525
+ }
13526
+ /** Hard gates every line passes, whoever proposed it. */
13527
+ allowed(k) {
13528
+ if (this.audioLocked || this.inflight || this.o.now() - this.lastAt < this.o.minGapMs || k === this.lastKind && (k !== "backchannel" || this.o.now() - this.lastAt < this.o.backchannelGapMs)) return false;
13529
+ if (k === "backchannel") return this.o.aec && this.userSpeaking && this.main === "idle";
13530
+ if (k === "filler") return !this.userSpeaking && this.main === "idle";
13531
+ return this.working && !this.bridged;
13532
+ }
13533
+ /** 0ms rule: the candidate line for this trigger, or silence. */
13534
+ rule(trigger, pauseMs = 0) {
13535
+ const w = words2(this.heard);
13536
+ if (w.length <= 3 || QUICK.test(this.heard) && w.length <= 6) return "silence";
13537
+ const k = trigger === "interim" ? w.length >= this.o.backchannelMinWords && /[,.;:!?…]$/.test(this.heard) ? "backchannel" : null : trigger === "pause" ? pauseMs >= this.o.fillerPauseMs && w.length >= this.o.fillerMinWords ? "filler" : null : w.length >= this.o.bridgeMinWords ? "bridge" : null;
13538
+ return k && this.allowed(k) ? k : "silence";
13539
+ }
13540
+ async consider(trigger, pauseMs) {
13541
+ if (this.busy) return;
13542
+ const n0 = words2(this.heard).length;
13543
+ this.decidedAt = n0;
13544
+ const rule = this.rule(trigger, pauseMs);
13545
+ if (rule === "silence") return;
13546
+ this.busy = true;
13547
+ const gen = this.gen, t0 = this.o.now();
13548
+ const ev = (e) => this.o.onEvent({ trigger, rule, ms: Math.round(this.o.now() - t0), ...e });
13549
+ const stale = () => gen !== this.gen || words2(this.heard).length - n0 >= this.o.staleWords;
13550
+ try {
13551
+ let kind = rule;
13552
+ let choice;
13553
+ if (this.o.decide) {
13554
+ const d = await withBudget(this.o.decide(this.tail(), this.state(pauseMs)), this.o.decisionBudgetMs, "channel decision");
13555
+ choice = d?.choice;
13556
+ if (choice && choice in CHANNEL_ACTIONS) kind = choice;
13557
+ }
13558
+ if (kind === "silence") return ev({ choice, acted: "silence" });
13559
+ if (stale()) return ev({ choice, kind, acted: "stale" });
13560
+ if (!this.allowed(kind)) return ev({ choice, kind, acted: "gated" });
13561
+ const text = await this.line(kind);
13562
+ if (stale()) return ev({ choice, kind, text, acted: "stale" });
13563
+ if (!this.allowed(kind)) return ev({ choice, kind, text, acted: "gated" });
13564
+ ev({ choice, kind, text, acted: this.say(kind, text) ? "spoken" : "skipped" });
13565
+ } finally {
13566
+ this.busy = false;
13567
+ }
13568
+ }
13569
+ state(pauseMs) {
13570
+ const now4 = this.o.now();
13571
+ return {
13572
+ user_speaking: this.userSpeaking,
13573
+ pause_ms: pauseMs ?? null,
13574
+ agent_state: this.main,
13575
+ last_line: this.recent.at(-1) ?? null,
13576
+ ms_since_last_line: Number.isFinite(this.lastAt) ? Math.round(now4 - this.lastAt) : null,
13577
+ call_ms: Math.round(now4 - this.startedAt)
13578
+ };
13579
+ }
13580
+ async line(kind) {
13581
+ if (this.o.generate && this.o.generateFor.includes(kind)) {
13582
+ const ac = new AbortController();
13583
+ const ctx = { kind, heard: this.tail(), state: this.main, avoid: [...this.recent], ...this.lead ? { lead: this.lead } : {} };
13584
+ const raw = await withBudget(this.o.generate(ctx, ac.signal), this.o.generateBudgetMs, `${kind} generate`);
13585
+ if (raw === null) ac.abort();
13586
+ const text = cleanLine(raw ?? "");
13587
+ if (text && !this.recent.some((r) => norm2(r) === norm2(text))) return text;
13588
+ if (text) log29.debug(`generated "${text}" repeats a recent line \u2014 canned`);
13589
+ }
13590
+ return this.pick(kind);
13591
+ }
13592
+ /** Canned rotation: never one of the last `bankExclude` picks of this kind, nor a recent line. */
13593
+ pick(kind) {
13594
+ const pool = CHANNEL_BANK[kind];
13595
+ const hist = this.picks.get(kind) ?? [];
13596
+ const recent = new Set([...hist.slice(-this.o.bankExclude), ...this.recent].map(norm2));
13597
+ const fresh = pool.filter((x) => !recent.has(norm2(x)));
13598
+ const from = fresh.length ? fresh : pool;
13599
+ const p = from[Math.floor(this.o.random() * from.length)];
13600
+ this.picks.set(kind, [...hist, p].slice(-this.o.bankExclude));
13601
+ return p;
13602
+ }
13603
+ say(kind, text) {
13604
+ let r;
13605
+ try {
13606
+ r = this.o.speak(text, kind);
13607
+ } catch (e) {
13608
+ log29.warn(`speak threw \u2014 line dropped: ${e.message}`);
13609
+ return false;
13610
+ }
13611
+ if (r === false) return false;
13612
+ this.lastAt = this.o.now();
13613
+ this.lastKind = kind;
13614
+ if (kind === "bridge") this.bridged = true;
13615
+ this.recent = [...this.recent, text].slice(-this.o.avoidLines);
13616
+ this.live = text.trim();
13617
+ this.o.show(text, kind);
13618
+ const p = Promise.resolve(r).then(() => {
13619
+ }, (e) => {
13620
+ log29.warn(`channel line playback failed: ${e?.message ?? e}`);
13621
+ }).finally(() => {
13622
+ if (this.inflight === p) this.inflight = null;
13623
+ });
13624
+ this.inflight = p;
13625
+ return true;
13626
+ }
13627
+ };
13628
+ var norm2 = (s) => s.toLowerCase().replace(/[^a-z' ]/g, "").trim();
13629
+ function cleanLine(s) {
13630
+ const raw = s.trim().split("\n")[0];
13631
+ const l = raw.replace(/^["'“”]+|["'“”]+$/g, "").replace(/^\w+:\s*/, "").trim();
13632
+ if (isLeakSuspect(raw) || isLeakSuspect(l)) {
13633
+ if (l) log29.debug(`generated "${raw}" reads as meta \u2014 canned`);
13634
+ return "";
13635
+ }
13636
+ return words2(l).length > 6 ? "" : l;
13637
+ }
13638
+ var KIND_HINT = {
13639
+ backchannel: "a tiny listener cue while they keep talking \u2014 shows you follow THEIR point, does not take the turn",
13640
+ filler: "a soft thinking sound at their pause",
13641
+ bridge: "a quick lead-in before the real answer, while it is being prepared"
13642
+ };
13643
+ function fastLineGenerator(ai, model) {
13644
+ return async (c, signal) => {
13645
+ const prompt = `You voice the tiny side-sounds of a live spoken conversation. Write ONE line of 1-5 words: ${KIND_HINT[c.kind]}.
13646
+ The user just said: "${c.heard}"
13647
+ The assistant is: ${c.state}.
13648
+ ` + (c.lead ? `The real answer begins: "${c.lead.trim()}" \u2014 lead naturally into it; do not repeat it.
13649
+ ` : "") + (c.avoid.length ? `Do not reuse any of: ${c.avoid.map((a) => `"${a}"`).join(", ")}.
13650
+ ` : "") + "React to their actual words, casually. Output only the line.";
13651
+ const reasoning = /gpt-oss/.test(model) ? { providerOptions: { reasoning_effort: "low" } } : {};
13652
+ const r = await ai.chat({ model, messages: [{ role: "user", content: prompt }], stream: false, signal, maxTokens: 150, ...reasoning });
13653
+ return r.content ?? "";
13654
+ };
13655
+ }
13656
+
13254
13657
  // cli/config.ts
13255
13658
  import { homedir as homedir5 } from "os";
13256
13659
  import { existsSync as existsSync5, readFileSync as readFileSync7 } from "fs";
@@ -13397,7 +13800,7 @@ function applyOverlay(cfg, file) {
13397
13800
  // cli/hooks-config.ts
13398
13801
  import { spawnSync as spawnSync4 } from "child_process";
13399
13802
  init_platform();
13400
- var log29 = forComponent("hooks");
13803
+ var log30 = forComponent("hooks");
13401
13804
  var escapeRegex = (s) => s.replace(/[.+^${}()|[\]\\]/g, "\\$&");
13402
13805
  function ruleMatches(rule, toolName) {
13403
13806
  if (!rule.tool || rule.tool === "*") return true;
@@ -13416,7 +13819,7 @@ function runCmd(rule, env) {
13416
13819
  });
13417
13820
  return { code: r.status ?? 1, out: ((r.stdout ?? "") + (r.stderr ?? "")).trim() };
13418
13821
  } catch (e) {
13419
- log29.debug(`hook command failed: ${rule.command}`, e);
13822
+ log30.debug(`hook command failed: ${rule.command}`, e);
13420
13823
  return { code: 1, out: String(e?.message ?? e) };
13421
13824
  }
13422
13825
  }
@@ -13524,7 +13927,7 @@ import { existsSync as existsSync6, mkdirSync as mkdirSync8, readFileSync as rea
13524
13927
  import { homedir as homedir6 } from "os";
13525
13928
  import { join as join10 } from "path";
13526
13929
  init_platform();
13527
- var log30 = forComponent("session");
13930
+ var log31 = forComponent("session");
13528
13931
  var globalDir = () => join10(homedir6(), ".agent", "sessions");
13529
13932
  function indexLink(target, link2) {
13530
13933
  try {
@@ -13587,7 +13990,7 @@ var SessionStore = class {
13587
13990
  }
13588
13991
  load(id) {
13589
13992
  if (!this.safeId(id)) {
13590
- log30.debug(`rejecting unsafe session id: ${id}`);
13993
+ log31.debug(`rejecting unsafe session id: ${id}`);
13591
13994
  return void 0;
13592
13995
  }
13593
13996
  const path = join10(this.dir, `${id}.json`);
@@ -13595,7 +13998,7 @@ var SessionStore = class {
13595
13998
  try {
13596
13999
  return JSON.parse(readFileSync8(path, "utf8"));
13597
14000
  } catch (e) {
13598
- log30.debug(`unreadable session ${id} \u2014 ignoring`, e);
14001
+ log31.debug(`unreadable session ${id} \u2014 ignoring`, e);
13599
14002
  return void 0;
13600
14003
  }
13601
14004
  }
@@ -13608,7 +14011,7 @@ var SessionStore = class {
13608
14011
  try {
13609
14012
  metas.push(JSON.parse(readFileSync8(join10(this.dir, f), "utf8")).meta);
13610
14013
  } catch (e) {
13611
- log30.debug(`skipping unreadable session file ${f}`, e);
14014
+ log31.debug(`skipping unreadable session file ${f}`, e);
13612
14015
  }
13613
14016
  }
13614
14017
  return metas.sort((a, b) => b.updated - a.updated);
@@ -13847,7 +14250,7 @@ import { promisify } from "util";
13847
14250
  import { writeFileSync as writeFileSync6, mkdirSync as mkdirSync9 } from "fs";
13848
14251
  import { homedir as homedir7 } from "os";
13849
14252
  import { join as join11, resolve as resolve4, sep as sep3, parse as parsePath } from "path";
13850
- var log31 = forComponent("checkpoints");
14253
+ var log32 = forComponent("checkpoints");
13851
14254
  var exec = promisify(execFile4);
13852
14255
  var DEFAULT_EXCLUDE = [".agent/", ".git/", "node_modules/", "dist/", "build/", ".next/", "target/", ".venv/", "__pycache__/", "*.log"];
13853
14256
  function isBroadRoot(p) {
@@ -13893,7 +14296,7 @@ var ShadowRepo = class {
13893
14296
  writeFileSync6(join11(this.gitDir, "info", "exclude"), this.exclude.join("\n") + "\n");
13894
14297
  this.ready = true;
13895
14298
  } catch (e) {
13896
- log31.debug(`git checkpoints unavailable for ${this.workTree}`, e);
14299
+ log32.debug(`git checkpoints unavailable for ${this.workTree}`, e);
13897
14300
  this.ready = false;
13898
14301
  }
13899
14302
  return this.ready;
@@ -13904,7 +14307,7 @@ var ShadowRepo = class {
13904
14307
  }
13905
14308
  async commit(label, forced = []) {
13906
14309
  await this.run("add", "-A");
13907
- for (const p of forced) await this.run("add", "-f", "--", p).catch((e) => log31.debug(`force-add failed: ${p}`, e));
14310
+ for (const p of forced) await this.run("add", "-f", "--", p).catch((e) => log32.debug(`force-add failed: ${p}`, e));
13908
14311
  await this.run("commit", "--allow-empty", "-q", "-m", label);
13909
14312
  }
13910
14313
  /** Inject the CURRENT (pre-edit) content of `paths` into the turn-open restore point by amending it.
@@ -13912,8 +14315,8 @@ var ShadowRepo = class {
13912
14315
  * turn-boundary `add -A` would never have captured it. Amend (vs a new commit) keeps one restore
13913
14316
  * point per turn, so the REPL's turn↔frame mapping stays intact. */
13914
14317
  async amendForced(paths) {
13915
- for (const p of paths) await this.run("add", "-f", "--", p).catch((e) => log31.debug(`force-capture failed: ${p}`, e));
13916
- await this.run("commit", "--amend", "--no-edit", "-q", "--allow-empty").catch((e) => log31.debug("amend failed", e));
14318
+ for (const p of paths) await this.run("add", "-f", "--", p).catch((e) => log32.debug(`force-capture failed: ${p}`, e));
14319
+ await this.run("commit", "--amend", "--no-edit", "-q", "--allow-empty").catch((e) => log32.debug("amend failed", e));
13917
14320
  }
13918
14321
  /** Commits on `ref`, oldest-first (canonical index space). */
13919
14322
  async log(ref) {
@@ -13973,7 +14376,7 @@ var ShadowRepo = class {
13973
14376
  await this.run("gc", "--auto", "-q").catch(() => {
13974
14377
  });
13975
14378
  } catch (e) {
13976
- log31.debug("checkpoint prune failed", e);
14379
+ log32.debug("checkpoint prune failed", e);
13977
14380
  }
13978
14381
  }
13979
14382
  };
@@ -14041,7 +14444,7 @@ var GitCheckpoints = class {
14041
14444
  use(sessionId) {
14042
14445
  if (sessionId === this.session) return;
14043
14446
  this.session = sessionId;
14044
- if (this.started) for (const r of this.repos) void r.point(this.ref()).catch((e) => log31.debug("re-point failed", e));
14447
+ if (this.started) for (const r of this.repos) void r.point(this.ref()).catch((e) => log32.debug("re-point failed", e));
14045
14448
  }
14046
14449
  async begin(label) {
14047
14450
  if (!await this.start()) return;
@@ -14061,7 +14464,7 @@ var GitCheckpoints = class {
14061
14464
  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
14062
14465
  `);
14063
14466
  } else {
14064
- log31.debug("checkpoint commit failed", e);
14467
+ log32.debug("checkpoint commit failed", e);
14065
14468
  survivors.push(this.repos[i]);
14066
14469
  survivorCaches.push(this.caches[i] ?? []);
14067
14470
  }
@@ -14096,7 +14499,7 @@ var GitCheckpoints = class {
14096
14499
  if (this.forced.has(abs)) continue;
14097
14500
  this.forced.add(abs);
14098
14501
  const i = this.repoIndexFor(abs);
14099
- if (i >= 0) await this.repos[i].amendForced([abs]).catch((e) => log31.debug("amendForced failed", e));
14502
+ if (i >= 0) await this.repos[i].amendForced([abs]).catch((e) => log32.debug("amendForced failed", e));
14100
14503
  }
14101
14504
  }
14102
14505
  };
@@ -15881,7 +16284,7 @@ import { spawnSync as spawnSync6 } from "child_process";
15881
16284
  import { writeFileSync as writeFileSync8, mkdirSync as mkdirSync10, readdirSync as readdirSync2, unlinkSync as unlinkSync3, chmodSync, existsSync as existsSync7 } from "fs";
15882
16285
  import { homedir as homedir8 } from "os";
15883
16286
  import { join as join13 } from "path";
15884
- var log32 = forComponent("os-sched");
16287
+ var log33 = forComponent("os-sched");
15885
16288
  var OsScheduler = class {
15886
16289
  options;
15887
16290
  constructor(options) {
@@ -15943,7 +16346,7 @@ var OsScheduler = class {
15943
16346
  }
15944
16347
  }
15945
16348
  } catch (e) {
15946
- log32.debug(`cancel ${id}`, e);
16349
+ log33.debug(`cancel ${id}`, e);
15947
16350
  }
15948
16351
  for (const f of [`${id}.json`, `${id}.sh`]) {
15949
16352
  try {
@@ -16061,7 +16464,7 @@ import { existsSync as existsSync8, mkdirSync as mkdirSync11, unlinkSync as unli
16061
16464
  import { homedir as homedir9 } from "os";
16062
16465
  import { join as join14 } from "path";
16063
16466
  init_platform();
16064
- var log33 = forComponent("remote-trigger");
16467
+ var log34 = forComponent("remote-trigger");
16065
16468
  var TRIGGER_DIR = () => join14(homedir9(), ".agent", "triggers");
16066
16469
  var sockPath = (sessionId, dir = TRIGGER_DIR()) => join14(dir, `${sessionId}.sock`);
16067
16470
  var TriggerServer = class {
@@ -16099,13 +16502,13 @@ var TriggerServer = class {
16099
16502
  conn.end(JSON.stringify({ ok: false, error: String(e) }) + "\n");
16100
16503
  }
16101
16504
  });
16102
- conn.on("error", (e) => log33.debug("trigger conn error", e));
16505
+ conn.on("error", (e) => log34.debug("trigger conn error", e));
16103
16506
  });
16104
- this.server.on("error", (e) => log33.debug("trigger server error", e));
16507
+ this.server.on("error", (e) => log34.debug("trigger server error", e));
16105
16508
  this.server.listen(p);
16106
16509
  this.path = p;
16107
16510
  } catch (e) {
16108
- log33.debug("trigger server unavailable", e);
16511
+ log34.debug("trigger server unavailable", e);
16109
16512
  }
16110
16513
  }
16111
16514
  /** Re-bind on /resume (the session id changed). */
@@ -16237,7 +16640,7 @@ var strike = C("9");
16237
16640
  var link = (text, url) => useColor ? `\x1B]8;;${url}\x1B\\${cyan(text)}\x1B]8;;\x1B\\` : `${text} (${url})`;
16238
16641
  var mdPalette = { bold, dim, cyan, italic, strike, green, link };
16239
16642
  var err = (s) => process.stderr.write(s);
16240
- var log34 = forComponent("cli");
16643
+ var log35 = forComponent("cli");
16241
16644
  var VERSION = (() => {
16242
16645
  try {
16243
16646
  return JSON.parse(readFileSync10(new URL("../package.json", import.meta.url), "utf8")).version ?? "?";
@@ -16377,7 +16780,10 @@ function parseArgs(argv) {
16377
16780
  a.hardenNet = true;
16378
16781
  } else if (x === "--subagents") a.subagents = true;
16379
16782
  else if (x === "--duplex") a.duplex = true;
16380
- else if (x === "--conversational" || x === "--convo" || x === "--voice") {
16783
+ else if (x === "--duplex-v2") {
16784
+ a.duplex = true;
16785
+ a.duplexV2 = true;
16786
+ } else if (x === "--conversational" || x === "--convo" || x === "--voice") {
16381
16787
  a.voice = true;
16382
16788
  a.duplex = true;
16383
16789
  } else if (x === "--voice-model") a.voiceModel = val(++i, x);
@@ -16461,6 +16867,8 @@ Flags:
16461
16867
  --append-system-prompt <t> extra instructions appended to the system prompt for this run
16462
16868
  --duplex duplex mode: a fast voice model replies instantly and delegates real work
16463
16869
  to a background worker agent (-m model); results are re-voiced when ready
16870
+ --duplex-v2 duplex + a second voice channel: backchannels, fillers and bridges that never
16871
+ enter the agent's history (headless: stdin interim/pause/agent_state, stdout channel)
16464
16872
  --conversational duplex with a conversation-native register \u2014 short fast turns, fillers,
16465
16873
  impulsive reactions, human pacing (implies --duplex; aliases: --convo, --voice)
16466
16874
  with SONIOX_API_KEY + CARTESIA_API_KEY(+VOICE_ID) set: real voice I/O \u2014 mic in,
@@ -17063,7 +17471,7 @@ function turnCost(model, usage) {
17063
17471
  const priced = model.startsWith("claude-code/") ? `anthropic/${resolveModelAlias(model.slice("claude-code/".length))}` : model;
17064
17472
  return costOf(getModelInfo(priced)?.pricing, usage?.promptTokens ?? 0, usage?.completionTokens ?? 0, usage?.cacheCreationTokens ?? 0, usage?.cacheReadTokens ?? 0, priced);
17065
17473
  }
17066
- async function evaluateGoal(ai, condition, transcript, log35) {
17474
+ async function evaluateGoal(ai, condition, transcript, log36) {
17067
17475
  const recent = transcript.filter((m) => m.role === "assistant").slice(-8).map((m) => {
17068
17476
  const text = typeof m.content === "string" ? m.content : m.content.filter((p) => p.type === "text").map((p) => p.text).join(" ");
17069
17477
  return text.slice(0, 600);
@@ -17083,7 +17491,7 @@ ${recent}` }
17083
17491
  const match = r.content.match(/\{[\s\S]*\}/);
17084
17492
  if (match) return JSON.parse(match[0]);
17085
17493
  } catch (e) {
17086
- log35(dim(` (goal evaluator error: ${e?.message ?? e})
17494
+ log36(dim(` (goal evaluator error: ${e?.message ?? e})
17087
17495
  `));
17088
17496
  }
17089
17497
  return { met: false, reason: "evaluation unclear" };
@@ -17309,7 +17717,7 @@ function mcpAgentTools(mounted, opts) {
17309
17717
  return tools;
17310
17718
  }
17311
17719
  async function closeMcp(mounted) {
17312
- await Promise.all(mounted.map((m) => m.client.close().catch((e) => log34.debug("mcp close failed", e))));
17720
+ await Promise.all(mounted.map((m) => m.client.close().catch((e) => log35.debug("mcp close failed", e))));
17313
17721
  }
17314
17722
  var IMG_EXT = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp" };
17315
17723
  function mentionRefs(line) {
@@ -17368,7 +17776,7 @@ async function expandMentions(fs, line) {
17368
17776
  if (loaded.includes(ref) || missing.includes(ref)) continue;
17369
17777
  if (ref.includes(":") && mcpMentionResolver) {
17370
17778
  const body = await mcpMentionResolver(ref).catch((e) => {
17371
- log34.debug("mcp mention resolve failed", e);
17779
+ log35.debug("mcp mention resolve failed", e);
17372
17780
  return null;
17373
17781
  });
17374
17782
  if (body != null) {
@@ -17459,7 +17867,7 @@ async function runTurn(agent, store, session, task, cp, cwd = process.cwd(), sen
17459
17867
  try {
17460
17868
  store.save(session);
17461
17869
  } catch (ex) {
17462
- log34.debug("mid-turn session flush failed", ex);
17870
+ log35.debug("mid-turn session flush failed", ex);
17463
17871
  }
17464
17872
  }
17465
17873
  return origNotify(e);
@@ -17608,14 +18016,14 @@ var isCancelTeardown = (e) => {
17608
18016
  function installCancelGuards(mounted) {
17609
18017
  process.on("unhandledRejection", (e) => {
17610
18018
  if (isCancelTeardown(e)) {
17611
- log34.debug("suppressed unhandledRejection (cursor stream cancel)", e);
18019
+ log35.debug("suppressed unhandledRejection (cursor stream cancel)", e);
17612
18020
  return;
17613
18021
  }
17614
- log34.error("unhandledRejection", e);
18022
+ log35.error("unhandledRejection", e);
17615
18023
  });
17616
18024
  process.on("uncaughtException", (e) => {
17617
18025
  if (isCancelTeardown(e)) {
17618
- log34.debug("suppressed uncaughtException (cursor stream cancel)", e);
18026
+ log35.debug("suppressed uncaughtException (cursor stream cancel)", e);
17619
18027
  return;
17620
18028
  }
17621
18029
  console.error(e);
@@ -18040,7 +18448,7 @@ async function repl(args, ai, cfg, cwd) {
18040
18448
  try {
18041
18449
  mkdirSync12(dir, { recursive: true });
18042
18450
  } catch (e) {
18043
- log34.debug("paste image dir create failed", e);
18451
+ log35.debug("paste image dir create failed", e);
18044
18452
  }
18045
18453
  const img = grabClipboardImage(dir, String(Date.now()));
18046
18454
  return img ? { display: "Image", ref: "@" + img.path, path: img.path } : null;
@@ -18153,7 +18561,7 @@ Added entries are loadable now via the Skill/SlashCommand tools; removed ones ar
18153
18561
  mkdirSync12(join15(cwd, ".agent"), { recursive: true });
18154
18562
  appendFileSync(histPath, line + "\n");
18155
18563
  } catch (e) {
18156
- log34.debug("history write failed", e);
18564
+ log35.debug("history write failed", e);
18157
18565
  }
18158
18566
  };
18159
18567
  const ago = (t) => {
@@ -18225,7 +18633,7 @@ Added entries are loadable now via the Skill/SlashCommand tools; removed ones ar
18225
18633
  try {
18226
18634
  store.save(session);
18227
18635
  } catch (e) {
18228
- log34.debug("session save after rewind failed", e);
18636
+ log35.debug("session save after rewind failed", e);
18229
18637
  }
18230
18638
  err(green(" \u27F2 jumped back") + dim(` \u2014 ${face.transcript.length} message(s) kept; edit + resend
18231
18639
  `));
@@ -18260,7 +18668,7 @@ ${task}`;
18260
18668
  bangContext.length = 0;
18261
18669
  }
18262
18670
  const delta = await refreshCatalogs().catch((e) => {
18263
- log34.debug("catalog refresh failed", e);
18671
+ log35.debug("catalog refresh failed", e);
18264
18672
  return "";
18265
18673
  });
18266
18674
  if (delta) {
@@ -18523,7 +18931,7 @@ ${task}`;
18523
18931
  desc: "rescan skills/commands dirs and rebuild the system prompt (one cache miss) \u2014 picks up entries created mid-session",
18524
18932
  run: async () => {
18525
18933
  await refreshCatalogs().catch((e) => {
18526
- log34.debug("catalog refresh failed", e);
18934
+ log35.debug("catalog refresh failed", e);
18527
18935
  });
18528
18936
  face.reprepare();
18529
18937
  err(green(` \u2713 reloaded \u2014 ${skills.length} skill(s), ${cmds.length} command(s); system prompt rebuilds on next message
@@ -19026,7 +19434,7 @@ ${task}`;
19026
19434
  try {
19027
19435
  for (const def of (await loadAgents(fs2, d)).agents) if (!seen.has(def.name)) seen.set(def.name, { def, from: d });
19028
19436
  } catch (e) {
19029
- log34.debug(`loadAgents(${d}) failed`, e);
19437
+ log35.debug(`loadAgents(${d}) failed`, e);
19030
19438
  }
19031
19439
  }
19032
19440
  if (!seen.size) {
@@ -19114,7 +19522,7 @@ ${task}`;
19114
19522
  }
19115
19523
  if (idx >= 0) {
19116
19524
  const old = mounted.splice(idx, 1)[0];
19117
- await old.client.close().catch((e) => log34.debug("mcp close failed", e));
19525
+ await old.client.close().catch((e) => log35.debug("mcp close failed", e));
19118
19526
  }
19119
19527
  try {
19120
19528
  const m = await mountMcpServer(name, conf);
@@ -19142,7 +19550,7 @@ ${task}`;
19142
19550
  }
19143
19551
  const m = mounted.splice(idx, 1)[0];
19144
19552
  remountMcpTools();
19145
- await m.client.close().catch((e) => log34.debug("mcp close failed", e));
19553
+ await m.client.close().catch((e) => log35.debug("mcp close failed", e));
19146
19554
  err(dim(` removed "${name}"
19147
19555
  `));
19148
19556
  return;
@@ -19345,7 +19753,7 @@ ${task}`;
19345
19753
  try {
19346
19754
  return readdirSync4(join15(cwd, absDir.replace(/^\/+/, "")), { withFileTypes: true }).map((d) => ({ name: d.name, dir: d.isDirectory() }));
19347
19755
  } catch (e) {
19348
- log34.debug("completion readdir failed", absDir, e);
19756
+ log35.debug("completion readdir failed", absDir, e);
19349
19757
  return null;
19350
19758
  }
19351
19759
  };
@@ -19482,7 +19890,7 @@ ${out}
19482
19890
  return;
19483
19891
  }
19484
19892
  await refreshCatalogs().catch((e) => {
19485
- log34.debug("catalog refresh failed", e);
19893
+ log35.debug("catalog refresh failed", e);
19486
19894
  });
19487
19895
  const sk = skills.find((x) => x.name === name);
19488
19896
  if (sk) {
@@ -19593,7 +20001,7 @@ ${out}
19593
20001
  if (delivery || cut) dx?.parkCutDelivery(delivery, !!cut, heard);
19594
20002
  const cutReply = cut && cut.full.length - cut.heard.length > 40 ? cut : null;
19595
20003
  const noteP = replaces ? replaces.note : !cutReply ? Promise.resolve("") : (instinct ? instinct.bargeIntent(text).catch((e) => {
19596
- log34.warn(`barge intent failed \u2014 model decides: ${e.message}`);
20004
+ log35.warn(`barge intent failed \u2014 model decides: ${e.message}`);
19597
20005
  return void 0;
19598
20006
  }) : Promise.resolve(void 0)).then(async (intent) => interruptionNote(cutReply, intent) + ackedNote(await instinct?.ackFor(text)));
19599
20007
  const cutIn = replaces ? replaces.cutIn : interjectNote;
@@ -19688,6 +20096,14 @@ ${out}
19688
20096
  };
19689
20097
  try {
19690
20098
  await voiceIO.start();
20099
+ if (args.duplexV2 && dx) {
20100
+ const v = voiceIO;
20101
+ bindSecondChannel(v, makeSecondChannel(ai, dx.options.reflexModel, { show: (t) => {
20102
+ err("\r\x1B[0J" + dim(` \u21B3 ${t}
20103
+ `));
20104
+ editorRef?.redrawNow();
20105
+ } }), () => voiceIO === v);
20106
+ }
19691
20107
  const inDev = voiceIO.usingAec ? detectedInputDevice() : null;
19692
20108
  const inNote = inDev ? `, in: ${inDev.name}` : "";
19693
20109
  err(dim(` \u{1F3A4} voice on (${voiceIO.usingAec ? "echo-cancelled" : "heuristic echo \u2014 headphones recommended"}${inNote}) \u2014 just talk; speak over it to interrupt; say "voice off" to stop
@@ -19938,6 +20354,16 @@ function duplexError(e, reflexModel) {
19938
20354
  return code && !msg.includes(String(code)) ? `${msg} (${code})` : msg;
19939
20355
  }
19940
20356
  var leakDecider = () => process.env.TYPESAFE_AI_KEY ? new DecideClient({ apiKey: process.env.TYPESAFE_AI_KEY }) : void 0;
20357
+ function makeSecondChannel(ai, reflexModel, opts) {
20358
+ const decider = leakDecider();
20359
+ const instinct = decider ? new Instinct({ decider }) : void 0;
20360
+ return new SecondChannel({
20361
+ decide: instinct ? (heard, state) => instinct.channel(heard, state) : void 0,
20362
+ generate: fastLineGenerator(ai, process.env.AGENTX_CHANNEL_MODEL || reflexModel),
20363
+ onEvent: (e) => log35.debug(`channel ${e.trigger}: rule=${e.rule} choice=${e.choice ?? "-"} \u2192 ${e.acted}${e.text ? ` "${e.text}"` : ""} ${e.ms}ms`),
20364
+ ...opts
20365
+ });
20366
+ }
19941
20367
  async function duplexServe(args, ai, cfg, cwd) {
19942
20368
  const out = (o) => process.stdout.write(JSON.stringify(o) + "\n");
19943
20369
  const mounted = await mountMcp(cfg, new McpOAuth({ storePath: join15(cwd, ".agent", "mcp-auth.json") }));
@@ -19947,8 +20373,10 @@ async function duplexServe(args, ai, cfg, cwd) {
19947
20373
  const host = {
19948
20374
  notify(e) {
19949
20375
  const k = String(e.kind);
19950
- if (k === "text_delta") out({ type: "text", text: e.message });
19951
- else if (k === "thinking_delta") out({ type: "thinking", text: e.message });
20376
+ if (k === "text_delta") {
20377
+ out({ type: "text", text: e.message });
20378
+ sc?.onMainTokens(String(e.message ?? ""));
20379
+ } else if (k === "thinking_delta") out({ type: "thinking", text: e.message });
19952
20380
  else if (k === "speak_utterance") out({ type: "speak", text: e.message });
19953
20381
  else if (k === "hold_filler") out({ type: "filler", text: e.message });
19954
20382
  else if (k === "turn_replace") out({ type: "replaced", ...e.data });
@@ -19980,6 +20408,8 @@ async function duplexServe(args, ai, cfg, cwd) {
19980
20408
  reflexOptions: { fs: agent.options.fs, tools: [exitSessionTool(() => out({ type: "exit" }))] }
19981
20409
  });
19982
20410
  if (args.voiceSystemPrompt) dx.voice.options.systemPrompt += "\n\n" + args.voiceSystemPrompt;
20411
+ let scTurn = 0;
20412
+ const sc = args.duplexV2 ? makeSecondChannel(ai, dx.options.reflexModel, { show: (text, kind) => out({ type: "channel", kind, text }) }) : void 0;
19983
20413
  const store = new SessionStore(cwd);
19984
20414
  const session = startSession(args, store, dx.voice, cwd);
19985
20415
  persist = () => {
@@ -19988,7 +20418,7 @@ async function duplexServe(args, ai, cfg, cwd) {
19988
20418
  try {
19989
20419
  store.save(session);
19990
20420
  } catch (e) {
19991
- log34.warn("duplex session save failed", e);
20421
+ log35.warn("duplex session save failed", e);
19992
20422
  }
19993
20423
  };
19994
20424
  out({ type: "ready", sessionId: session.meta.id, reflexModel: dx.options.reflexModel, actModel: dx.options.actModel, ...notes.length ? { notes } : {} });
@@ -20002,20 +20432,30 @@ async function duplexServe(args, ai, cfg, cwd) {
20002
20432
  return;
20003
20433
  }
20004
20434
  if (m.type === "barge") {
20435
+ sc?.onBargeIn();
20005
20436
  activeTurn?.abort();
20006
20437
  dx.parkInFlightDeliveries();
20438
+ } else if (sc && channelInput(sc, m)) {
20007
20439
  } else if (m.type === "cancel") out({ type: "task", kind: "cancel_result", id: m.task, message: dx.cancelTask(String(m.task)) });
20008
20440
  else if (m.type === "exit") rl.close();
20009
20441
  else if (m.type === "user" && typeof m.text === "string" && m.text.trim()) {
20010
- const { reply, delivery, heard } = splitCut(m.cut ?? null);
20442
+ const cut = m.cut && sc?.spoke(String(m.cut.full ?? "")) ? { ...m.cut, full: "", heard: "" } : m.cut;
20443
+ const { reply, delivery, heard } = splitCut(cut ?? null);
20011
20444
  if (delivery || reply) dx.parkCutDelivery(delivery, !!reply, heard);
20012
20445
  const note = reply && reply.full.length - reply.heard.length > 40 ? interruptionNote(reply) : "";
20013
20446
  const id = m.id ? String(m.id) : void 0;
20447
+ sc?.onUserTurn(m.text);
20448
+ const seq = ++scTurn;
20449
+ const done2 = () => {
20450
+ if (seq === scTurn) sc?.onAgentState("idle");
20451
+ };
20014
20452
  void runTurn(dx.voice, store, session, m.text + note, void 0, cwd, (c) => dx.send(c, { id, replaces: m.replaces ? String(m.replaces) : void 0, followUp: typeof m.followUp === "string" || m.followUp && typeof m.followUp === "object" ? m.followUp : void 0 })).then(({ res, error }) => {
20015
20453
  const why = (res?.finishReason ?? "error") === "error" ? duplexError(res?.error ?? error, dx.options.reflexModel) : "";
20454
+ done2();
20016
20455
  out({ type: "turn_done", id, finishReason: res?.finishReason ?? "error", ...why ? { error: why } : {} });
20017
20456
  }).catch((e) => {
20018
- log34.error("duplex turn failed", e);
20457
+ log35.error("duplex turn failed", e);
20458
+ done2();
20019
20459
  out({ type: "turn_done", id, finishReason: "error", error: String(e?.message ?? e) });
20020
20460
  });
20021
20461
  }