@livx.cc/agentx 0.99.94 → 0.99.96

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
@@ -10308,6 +10308,37 @@ ${nowLine()}. Anchor every relative or time-sensitive reference \u2014 "today",
10308
10308
  }
10309
10309
  if (this.silentTurn && res?.finishReason === "error" && refusedCall(res.error)) this.emitHost({ kind: "text_delta", message: fallback ?? _DuplexAgent.FALLBACK_ERROR });
10310
10310
  else if (this.silentTurn) await this.ackIfSilent(fallback);
10311
+ else if (this.hollowPromise) await this.followThrough();
10312
+ }
10313
+ /** The turn SAID it is on it ("got it — checking…") but called no tool and nothing is running: a promise nothing
10314
+ * keeps — the user waits on silence (live Para #798: a follow-up while the reflex believed a finished task still ran). */
10315
+ get hollowPromise() {
10316
+ if (!this.options.host || this.turnDispatched || this.leakHit || this.fabricationCut) return false;
10317
+ if (this.voice.transcript.slice(this.turnBase).some((m) => m.tool_calls?.length)) return false;
10318
+ if ([...this.tasks.values()].some((t) => t.status === "running")) return false;
10319
+ return _DuplexAgent.PROMISE_RE.test(this.turnSpoken);
10320
+ }
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;
10322
+ 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
+ /** Re-prompt the reflex ONCE, tools allowed, to keep its hollow promise; the reminder never stays in the transcript. */
10324
+ async followThrough() {
10325
+ const base = this.voice.transcript.length, reminder = _DuplexAgent.FOLLOW_THROUGH;
10326
+ this.notify("diag", "hollow_promise", { text: this.turnSpoken.trim().slice(0, 80) });
10327
+ this.turnSpoken = "";
10328
+ this.reflexBuf = "";
10329
+ this.reflexForwarded = 0;
10330
+ this.stepMarks = [];
10331
+ this.dropJudge();
10332
+ try {
10333
+ await this.voice.send(reminder);
10334
+ } catch (e) {
10335
+ log21.warn(`follow-through nudge failed: ${e instanceof Error ? e.message : e}`);
10336
+ } finally {
10337
+ const i = this.voice.transcript.findIndex((m, k) => k >= base && m.role === "user" && m.content === reminder);
10338
+ if (i >= 0) this.voice.transcript.splice(i, 1);
10339
+ await this.drainReflex();
10340
+ this.scrubLeakFromTranscript();
10341
+ }
10311
10342
  }
10312
10343
  /** Rewrite the UNFORWARDED reflex text: drop complete stage-direction parentheticals (STAGE_DIRECTION_RE) and spoken
10313
10344
  * junk (scrubSpokenJunk). Only the unforwarded region is touched — already-spoken audio can't be unsent, and
@@ -13414,6 +13445,11 @@ function bindSecondChannel(v, sc, live) {
13414
13445
  sc.onBargeIn();
13415
13446
  onBargeIn(phase);
13416
13447
  };
13448
+ const speakDelta = v.speakDelta.bind(v);
13449
+ v.speakDelta = ((t, ...rest) => {
13450
+ if (live()) sc.noteReply(t);
13451
+ return speakDelta(t, ...rest);
13452
+ });
13417
13453
  Object.assign(sc.options, {
13418
13454
  aec: v.usingAec,
13419
13455
  speak: (text, kind) => {
@@ -13626,6 +13662,9 @@ var JsonlDiagSink = class {
13626
13662
  // src/voice/secondChannel.ts
13627
13663
  init_logging();
13628
13664
 
13665
+ // src/voice/backchannelPlan.ts
13666
+ init_logging();
13667
+
13629
13668
  // src/voice/channelLang.ts
13630
13669
  init_logging();
13631
13670
  import { mkdir, readFile, writeFile } from "fs/promises";
@@ -13860,8 +13899,79 @@ var ChannelBanks = class {
13860
13899
  }
13861
13900
  };
13862
13901
 
13902
+ // src/voice/backchannelPlan.ts
13903
+ var log30 = forComponent("BackchannelPlan");
13904
+ var TURN_TYPES = ["story", "list", "complaint", "explanation", "short_command", "question", "other"];
13905
+ var DENSITIES = ["none", "low", "normal", "high"];
13906
+ var silentTurn = (p) => p.density === "none" || p.turn === "short_command" || p.turn === "question";
13907
+ var cueWords = (s) => s.toLowerCase().replace(/[^\p{L}\p{N}'\s]+/gu, " ").split(/\s+/).filter(Boolean);
13908
+ function parsePlan(raw, lang, maxCues = 8) {
13909
+ const m = raw.match(/\{[\s\S]*\}/);
13910
+ if (!m) return null;
13911
+ let o;
13912
+ try {
13913
+ o = JSON.parse(m[0]);
13914
+ } catch {
13915
+ return null;
13916
+ }
13917
+ const turn = String(o?.turn ?? "").toLowerCase().replace(/[\s-]+/g, "_");
13918
+ const density = String(o?.density ?? "").toLowerCase();
13919
+ if (!TURN_TYPES.includes(turn) || !DENSITIES.includes(density)) return null;
13920
+ const cues = [];
13921
+ for (const c of Array.isArray(o.cues) ? o.cues : []) {
13922
+ 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);
13923
+ const line = typeof c?.line === "string" ? cleanLine(c.line, lang, 4) : "";
13924
+ if (when.length && line) cues.push({ when: [...new Set(when)], line });
13925
+ if (cues.length >= maxCues) break;
13926
+ }
13927
+ return { turn, density, cues };
13928
+ }
13929
+ function matchCue(plan, heard, from = 0) {
13930
+ if (!plan || silentTurn(plan)) return null;
13931
+ const w = cueWords(heard);
13932
+ for (let i = Math.max(0, from); i < w.length; i++) {
13933
+ for (const c of plan.cues) {
13934
+ for (const p of c.when) {
13935
+ const pw = p.split(" ");
13936
+ if (i + pw.length <= w.length && pw.every((x, j) => w[i + j] === x)) return c;
13937
+ }
13938
+ }
13939
+ }
13940
+ return null;
13941
+ }
13942
+ var BackchannelPlannerOptions = class {
13943
+ /** Fast model (Groq gpt-oss / the reflex). */
13944
+ model = "groq/openai/gpt-oss-120b";
13945
+ maxTokens = 900;
13946
+ };
13947
+ var BackchannelPlanner = class {
13948
+ constructor(ai, options) {
13949
+ this.ai = ai;
13950
+ this.options = options;
13951
+ this.options = { ...new BackchannelPlannerOptions(), ...options };
13952
+ }
13953
+ ai;
13954
+ options;
13955
+ async plan(c, signal) {
13956
+ const o = this.options;
13957
+ const name = languageName(c.lang);
13958
+ 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.
13959
+ Assistant just said: "${c.agent.slice(-400)}"
13960
+ ` + (c.user ? `User's previous turn ended: "${c.user.slice(-200)}"
13961
+ ` : "") + (c.heard ? `User so far in this turn: "${c.heard.slice(-300)}"
13962
+ ` : "") + `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).
13963
+ 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.
13964
+ Reply JSON only: {"turn":"...","density":"...","cues":[{"when":["phrase",...],"line":"..."}]}. Phrases in the user's language.`;
13965
+ const reasoning = /gpt-oss/.test(o.model) ? { providerOptions: { reasoning_effort: "low" } } : {};
13966
+ const r = await this.ai.chat({ model: o.model, messages: [{ role: "user", content: prompt }], stream: false, signal, maxTokens: o.maxTokens, ...reasoning });
13967
+ const p = parsePlan(r.content ?? "", c.lang);
13968
+ if (!p) log30.debug(`unusable plan: ${(r.content ?? "").slice(0, 120)}`);
13969
+ return p;
13970
+ }
13971
+ };
13972
+
13863
13973
  // src/voice/secondChannel.ts
13864
- var log30 = forComponent("SecondChannel");
13974
+ var log31 = forComponent("SecondChannel");
13865
13975
  var SecondChannelOptions = class {
13866
13976
  /** Refiner (Jev/fast LLM): the choice over CHANNEL_ACTIONS, or null on failure. Absent → the rule alone decides. */
13867
13977
  decide;
@@ -13910,6 +14020,23 @@ var SecondChannelOptions = class {
13910
14020
  fillerPauseMs = 700;
13911
14021
  fillerMinWords = 4;
13912
14022
  bridgeMinWords = 5;
14023
+ /** ANTICIPATE (backchannelPlan.ts): plan the user's next turn off the hot path (after the agent's reply / at turn start,
14024
+ * revised once at `reviseAtWords`); live, a backchannel fires only on a planned cue, at a `cuePauseMs` breath, with the
14025
+ * cue's line and no decider call. A silent plan (short command / question / density none) vetoes backchannels. No plan
14026
+ * yet / failed / late → the reactive path. Off by default (AGENTX_CHANNEL_ANTICIPATE=1). */
14027
+ anticipate = false;
14028
+ plan;
14029
+ planBudgetMs = 4e3;
14030
+ reviseAtWords = 7;
14031
+ /** A predicted moment needs only a short breath (the decision is already made). */
14032
+ cuePauseMs = 200;
14033
+ /** A cue counts only within the last N words (older matches were gated or passed). */
14034
+ cueWindow = 6;
14035
+ /** true: a plan fires on its cues only. false: a non-silent plan with density normal/high also lets the reactive breath
14036
+ * backchannel through (the plan then only vetoes short commands / questions / low density). */
14037
+ cueOnly = true;
14038
+ onPlan = () => {
14039
+ };
13913
14040
  now = () => performance.now();
13914
14041
  random = Math.random;
13915
14042
  };
@@ -13918,12 +14045,12 @@ function withBudget(p, ms, what) {
13918
14045
  let t;
13919
14046
  const late = new Promise((r) => {
13920
14047
  t = setTimeout(() => {
13921
- log30.debug(`${what}: over the ${ms}ms budget \u2014 dropped`);
14048
+ log31.debug(`${what}: over the ${ms}ms budget \u2014 dropped`);
13922
14049
  r(null);
13923
14050
  }, ms);
13924
14051
  });
13925
14052
  return Promise.race([p.catch((e) => {
13926
- log30.warn(`${what} failed \u2014 falling back: ${e?.message ?? e}`);
14053
+ log31.warn(`${what} failed \u2014 falling back: ${e?.message ?? e}`);
13927
14054
  return null;
13928
14055
  }), late]).finally(() => clearTimeout(t));
13929
14056
  }
@@ -13960,6 +14087,18 @@ var SecondChannel = class {
13960
14087
  startedAt;
13961
14088
  lang = null;
13962
14089
  pinned = false;
14090
+ // anticipate: the plan for the user's current/next turn (turnNo bumps per dispatched turn → a late plan is dropped)
14091
+ turnNo = 0;
14092
+ plan = null;
14093
+ planning = false;
14094
+ planTries = 0;
14095
+ // per turn: the reply-end plan + one turn-start retry, never a call per interim
14096
+ revised = false;
14097
+ cueFrom = 0;
14098
+ // word index after the last planned backchannel
14099
+ reply = "";
14100
+ // channel-1's text this turn (the plan's context)
14101
+ lastUser = "";
13963
14102
  /** The language lines are spoken in (detected from the user's words, or set by the host). */
13964
14103
  get language() {
13965
14104
  return this.lang;
@@ -13971,7 +14110,7 @@ var SecondChannel = class {
13971
14110
  if (host) this.pinned = true;
13972
14111
  const lang = primaryLang(tag);
13973
14112
  if (!lang || lang === this.lang) return;
13974
- log30.debug(`language ${this.lang} \u2192 ${lang}`);
14113
+ log31.debug(`language ${this.lang} \u2192 ${lang}`);
13975
14114
  this.lang = lang;
13976
14115
  this.recent = [];
13977
14116
  this.picks.clear();
@@ -14003,11 +14142,21 @@ var SecondChannel = class {
14003
14142
  this.pauseTimer = null;
14004
14143
  this.onPause(this.o.fillerPauseMs);
14005
14144
  }, this.o.fillerPauseMs);
14145
+ let cue = null;
14146
+ if (this.o.anticipate) {
14147
+ const n = words2(t).length;
14148
+ if (!this.plan && !this.planning && this.planTries < 2) this.requestPlan();
14149
+ else if (this.plan && !this.revised && n >= this.o.reviseAtWords) {
14150
+ this.revised = true;
14151
+ this.requestPlan(t);
14152
+ }
14153
+ cue = matchCue(this.plan, t, Math.max(this.cueFrom, n - this.o.cueWindow));
14154
+ }
14006
14155
  if (this.bcTimer) clearTimeout(this.bcTimer);
14007
14156
  this.bcTimer = this.o.aec ? setTimeout(() => {
14008
14157
  this.bcTimer = null;
14009
- if (this.userSpeaking && this.heard === t) void this.consider("interim", 0, true);
14010
- }, this.o.backchannelPauseMs) : null;
14158
+ if (this.userSpeaking && this.heard === t) void this.consider("interim", 0, true, cue);
14159
+ }, cue ? this.o.cuePauseMs : this.o.backchannelPauseMs) : null;
14011
14160
  if (words2(t).length - this.decidedAt >= this.o.decideEveryWords) void this.consider("interim");
14012
14161
  }
14013
14162
  /** The user went quiet for `ms` (host VAD/STT pause, or the internal interim timer). */
@@ -14036,9 +14185,17 @@ var SecondChannel = class {
14036
14185
  this.heard = "";
14037
14186
  this.decidedAt = 0;
14038
14187
  this.bridged = false;
14188
+ this.cueFrom = 0;
14189
+ if (this.o.anticipate && this.reply.trim() && !this.plan && !this.planning) this.requestPlan();
14039
14190
  return;
14040
14191
  }
14041
14192
  if (s === "queued") {
14193
+ this.turnNo++;
14194
+ this.plan = null;
14195
+ this.planning = false;
14196
+ this.planTries = 0;
14197
+ this.revised = false;
14198
+ this.reply = "";
14042
14199
  this.gen++;
14043
14200
  this.userSpeaking = false;
14044
14201
  this.bridged = false;
@@ -14046,24 +14203,31 @@ var SecondChannel = class {
14046
14203
  if (this[k]) clearTimeout(this[k]);
14047
14204
  this[k] = null;
14048
14205
  }
14049
- if (this.o.bridgeDelayMs > 0) this.bridgeTimer = setTimeout(() => {
14206
+ const wait = Math.max(this.o.bridgeDelayMs, this.o.minGapMs - (this.o.now() - this.lastAt));
14207
+ if (wait > 0) this.bridgeTimer = setTimeout(() => {
14050
14208
  this.bridgeTimer = null;
14051
14209
  void this.consider("working");
14052
- }, this.o.bridgeDelayMs);
14210
+ }, wait);
14053
14211
  else void this.consider("working");
14054
14212
  }
14055
14213
  }
14056
14214
  /** The user's final line was dispatched to the main agent (its words are the bridge's micro-context). */
14057
14215
  onUserTurn(text) {
14058
14216
  this.heard = text.trim();
14217
+ this.lastUser = this.heard;
14059
14218
  this.detect(this.heard);
14060
14219
  this.main = "idle";
14061
14220
  this.onAgentState("queued");
14062
14221
  }
14063
14222
  /** The main answer's streamed text (before its audio): channel-1 is responding — no bridge from here on. */
14064
14223
  onMainTokens(text) {
14224
+ this.noteReply(text);
14065
14225
  if (text.trim() && (this.main === "queued" || this.main === "thinking")) this.onAgentState("streaming_text");
14066
14226
  }
14227
+ /** Channel-1's reply text (a delta) — only the plan's context; no state change (hosts without onMainTokens). */
14228
+ noteReply(text) {
14229
+ this.reply += text;
14230
+ }
14067
14231
  /** The user barged in: cut the in-flight line now and drop every pending decision. */
14068
14232
  onBargeIn() {
14069
14233
  this.gen++;
@@ -14073,7 +14237,7 @@ var SecondChannel = class {
14073
14237
  try {
14074
14238
  this.o.cancel();
14075
14239
  } catch (e) {
14076
- log30.warn(`cancel threw: ${e.message}`);
14240
+ log31.warn(`cancel threw: ${e.message}`);
14077
14241
  }
14078
14242
  }
14079
14243
  }
@@ -14102,13 +14266,31 @@ var SecondChannel = class {
14102
14266
  return (this.main === "queued" || this.main === "thinking") && !this.bridged;
14103
14267
  }
14104
14268
  /** 0ms rule: the candidate line for this trigger, or silence. */
14105
- rule(trigger, pauseMs = 0, breath = false) {
14269
+ rule(trigger, pauseMs = 0, breath = false, cue = null) {
14106
14270
  const w = words2(this.heard);
14107
14271
  if (!this.lang) return "silence";
14108
14272
  if (w.length <= 3 || w.length <= 6 && isPhatic(this.heard, this.bank)) return "silence";
14109
- 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;
14273
+ 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;
14110
14274
  return k && this.allowed(k) ? k : "silence";
14111
14275
  }
14276
+ /** Plan (or, with `heard`, revise) the backchannels of the user's turn — async, never on the hot path. */
14277
+ requestPlan(heard) {
14278
+ if (!this.o.plan || !this.lang) return;
14279
+ const turn = this.turnNo, t0 = this.o.now(), ac = new AbortController();
14280
+ if (!heard) {
14281
+ this.planning = true;
14282
+ this.planTries++;
14283
+ }
14284
+ const ctx = { agent: this.reply.trim(), user: words2(this.lastUser).slice(-30).join(" "), recent: [...this.recent], lang: this.lang, heard };
14285
+ void withBudget(this.o.plan(ctx, ac.signal), this.o.planBudgetMs, heard ? "plan revision" : "plan").then((p) => {
14286
+ if (p === null) ac.abort();
14287
+ if (turn !== this.turnNo) return;
14288
+ if (!heard) this.planning = false;
14289
+ if (!p) return;
14290
+ this.plan = p;
14291
+ this.o.onPlan(p, !!heard, Math.round(this.o.now() - t0));
14292
+ });
14293
+ }
14112
14294
  /** A clause boundary mid-floor: a breath or a comma-like close. Not a question (it wants the answer, not "mm-hm"), and a
14113
14295
  * sentence end only deep into a story — a short request's end is the end of their turn (replay: "mm-hm" right after
14114
14296
  * "Check hacker news for recent news.", just before the final). */
@@ -14117,32 +14299,36 @@ var SecondChannel = class {
14117
14299
  if (/[.!]$/.test(this.heard)) return n >= 2 * this.o.backchannelMinWords;
14118
14300
  return breath || /[,;:…]$/.test(this.heard);
14119
14301
  }
14120
- async consider(trigger, pauseMs, breath = false) {
14302
+ async consider(trigger, pauseMs, breath = false, cue = null) {
14121
14303
  if (this.busy) return;
14122
14304
  const n0 = words2(this.heard).length;
14123
14305
  this.decidedAt = n0;
14124
- const rule = this.rule(trigger, pauseMs, breath);
14306
+ if (!(this.o.anticipate && this.plan && trigger === "interim")) cue = null;
14307
+ const rule = this.rule(trigger, pauseMs, breath, cue);
14125
14308
  if (rule === "silence") return;
14126
14309
  this.busy = true;
14127
14310
  const gen = this.gen, t0 = this.o.now();
14128
- const ev = (e) => this.o.onEvent({ trigger, rule, ms: Math.round(this.o.now() - t0), ...e });
14311
+ const ev = (e) => this.o.onEvent({ trigger, rule, ms: Math.round(this.o.now() - t0), ...cue ? { cue: cue.line } : {}, ...e });
14129
14312
  const stale = () => gen !== this.gen || words2(this.heard).length - n0 >= this.o.staleWords;
14130
14313
  try {
14131
14314
  let kind = rule;
14132
14315
  let choice;
14133
- if (this.o.decide) {
14316
+ if (this.o.decide && !cue) {
14134
14317
  const d = await withBudget(this.o.decide(this.tail(), this.state(pauseMs)), this.o.decisionBudgetMs, "channel decision");
14135
14318
  choice = d?.choice;
14136
- if (choice && choice in CHANNEL_ACTIONS) kind = choice;
14319
+ if (choice && choice in CHANNEL_ACTIONS && (choice === "silence" || this.allowed(choice))) kind = choice;
14320
+ else if (choice && choice in CHANNEL_ACTIONS) log31.debug(`decider chose ${choice} for a ${rule} moment \u2014 the rule stands`);
14137
14321
  }
14138
14322
  if (kind === "silence") return ev({ choice, acted: "silence" });
14139
14323
  if (stale()) return ev({ choice, kind, acted: "stale" });
14140
14324
  if (!this.allowed(kind)) return ev({ choice, kind, acted: "gated" });
14141
- const text = await this.line(kind);
14325
+ const text = cue && !this.recent.some((r) => norm2(r) === norm2(cue.line)) ? cue.line : await this.line(kind);
14142
14326
  if (!text) return ev({ choice, kind, acted: "no_bank" });
14143
14327
  if (stale()) return ev({ choice, kind, text, acted: "stale" });
14144
14328
  if (!this.allowed(kind)) return ev({ choice, kind, text, acted: "gated" });
14145
- ev({ choice, kind, text, acted: this.say(kind, text) ? "spoken" : "skipped" });
14329
+ const said = this.say(kind, text);
14330
+ if (said && cue) this.cueFrom = words2(this.heard).length;
14331
+ ev({ choice, kind, text, acted: said ? "spoken" : "skipped" });
14146
14332
  } finally {
14147
14333
  this.busy = false;
14148
14334
  }
@@ -14166,7 +14352,7 @@ var SecondChannel = class {
14166
14352
  if (raw === null) ac.abort();
14167
14353
  const text = cleanLine(raw ?? "", this.lang);
14168
14354
  if (text && !this.recent.some((r) => norm2(r) === norm2(text))) return text;
14169
- if (text) log30.debug(`generated "${text}" repeats a recent line \u2014 canned`);
14355
+ if (text) log31.debug(`generated "${text}" repeats a recent line \u2014 canned`);
14170
14356
  }
14171
14357
  return this.pick(kind);
14172
14358
  }
@@ -14188,7 +14374,7 @@ var SecondChannel = class {
14188
14374
  try {
14189
14375
  r = this.o.speak(text, kind);
14190
14376
  } catch (e) {
14191
- log30.warn(`speak threw \u2014 line dropped: ${e.message}`);
14377
+ log31.warn(`speak threw \u2014 line dropped: ${e.message}`);
14192
14378
  return false;
14193
14379
  }
14194
14380
  if (r === false) return false;
@@ -14200,7 +14386,7 @@ var SecondChannel = class {
14200
14386
  this.o.show(text, kind);
14201
14387
  const p = Promise.resolve(r).then(() => {
14202
14388
  }, (e) => {
14203
- log30.warn(`channel line playback failed: ${e?.message ?? e}`);
14389
+ log31.warn(`channel line playback failed: ${e?.message ?? e}`);
14204
14390
  }).finally(() => {
14205
14391
  if (this.inflight === p) this.inflight = null;
14206
14392
  });
@@ -14372,7 +14558,7 @@ function applyOverlay(cfg, file) {
14372
14558
  // cli/hooks-config.ts
14373
14559
  import { spawnSync as spawnSync4 } from "child_process";
14374
14560
  init_platform();
14375
- var log31 = forComponent("hooks");
14561
+ var log32 = forComponent("hooks");
14376
14562
  var escapeRegex = (s) => s.replace(/[.+^${}()|[\]\\]/g, "\\$&");
14377
14563
  function ruleMatches(rule, toolName) {
14378
14564
  if (!rule.tool || rule.tool === "*") return true;
@@ -14391,7 +14577,7 @@ function runCmd(rule, env) {
14391
14577
  });
14392
14578
  return { code: r.status ?? 1, out: ((r.stdout ?? "") + (r.stderr ?? "")).trim() };
14393
14579
  } catch (e) {
14394
- log31.debug(`hook command failed: ${rule.command}`, e);
14580
+ log32.debug(`hook command failed: ${rule.command}`, e);
14395
14581
  return { code: 1, out: String(e?.message ?? e) };
14396
14582
  }
14397
14583
  }
@@ -14499,7 +14685,7 @@ import { existsSync as existsSync6, mkdirSync as mkdirSync8, readFileSync as rea
14499
14685
  import { homedir as homedir6 } from "os";
14500
14686
  import { join as join11 } from "path";
14501
14687
  init_platform();
14502
- var log32 = forComponent("session");
14688
+ var log33 = forComponent("session");
14503
14689
  var globalDir = () => join11(homedir6(), ".agent", "sessions");
14504
14690
  function indexLink(target, link2) {
14505
14691
  try {
@@ -14562,7 +14748,7 @@ var SessionStore = class {
14562
14748
  }
14563
14749
  load(id) {
14564
14750
  if (!this.safeId(id)) {
14565
- log32.debug(`rejecting unsafe session id: ${id}`);
14751
+ log33.debug(`rejecting unsafe session id: ${id}`);
14566
14752
  return void 0;
14567
14753
  }
14568
14754
  const path = join11(this.dir, `${id}.json`);
@@ -14570,7 +14756,7 @@ var SessionStore = class {
14570
14756
  try {
14571
14757
  return JSON.parse(readFileSync8(path, "utf8"));
14572
14758
  } catch (e) {
14573
- log32.debug(`unreadable session ${id} \u2014 ignoring`, e);
14759
+ log33.debug(`unreadable session ${id} \u2014 ignoring`, e);
14574
14760
  return void 0;
14575
14761
  }
14576
14762
  }
@@ -14583,7 +14769,7 @@ var SessionStore = class {
14583
14769
  try {
14584
14770
  metas.push(JSON.parse(readFileSync8(join11(this.dir, f), "utf8")).meta);
14585
14771
  } catch (e) {
14586
- log32.debug(`skipping unreadable session file ${f}`, e);
14772
+ log33.debug(`skipping unreadable session file ${f}`, e);
14587
14773
  }
14588
14774
  }
14589
14775
  return metas.sort((a, b) => b.updated - a.updated);
@@ -14822,7 +15008,7 @@ import { promisify } from "util";
14822
15008
  import { writeFileSync as writeFileSync6, mkdirSync as mkdirSync9 } from "fs";
14823
15009
  import { homedir as homedir7 } from "os";
14824
15010
  import { join as join12, resolve as resolve4, sep as sep3, parse as parsePath } from "path";
14825
- var log33 = forComponent("checkpoints");
15011
+ var log34 = forComponent("checkpoints");
14826
15012
  var exec = promisify(execFile4);
14827
15013
  var DEFAULT_EXCLUDE = [".agent/", ".git/", "node_modules/", "dist/", "build/", ".next/", "target/", ".venv/", "__pycache__/", "*.log"];
14828
15014
  function isBroadRoot(p) {
@@ -14868,7 +15054,7 @@ var ShadowRepo = class {
14868
15054
  writeFileSync6(join12(this.gitDir, "info", "exclude"), this.exclude.join("\n") + "\n");
14869
15055
  this.ready = true;
14870
15056
  } catch (e) {
14871
- log33.debug(`git checkpoints unavailable for ${this.workTree}`, e);
15057
+ log34.debug(`git checkpoints unavailable for ${this.workTree}`, e);
14872
15058
  this.ready = false;
14873
15059
  }
14874
15060
  return this.ready;
@@ -14879,7 +15065,7 @@ var ShadowRepo = class {
14879
15065
  }
14880
15066
  async commit(label, forced = []) {
14881
15067
  await this.run("add", "-A");
14882
- for (const p of forced) await this.run("add", "-f", "--", p).catch((e) => log33.debug(`force-add failed: ${p}`, e));
15068
+ for (const p of forced) await this.run("add", "-f", "--", p).catch((e) => log34.debug(`force-add failed: ${p}`, e));
14883
15069
  await this.run("commit", "--allow-empty", "-q", "-m", label);
14884
15070
  }
14885
15071
  /** Inject the CURRENT (pre-edit) content of `paths` into the turn-open restore point by amending it.
@@ -14887,8 +15073,8 @@ var ShadowRepo = class {
14887
15073
  * turn-boundary `add -A` would never have captured it. Amend (vs a new commit) keeps one restore
14888
15074
  * point per turn, so the REPL's turn↔frame mapping stays intact. */
14889
15075
  async amendForced(paths) {
14890
- for (const p of paths) await this.run("add", "-f", "--", p).catch((e) => log33.debug(`force-capture failed: ${p}`, e));
14891
- await this.run("commit", "--amend", "--no-edit", "-q", "--allow-empty").catch((e) => log33.debug("amend failed", e));
15076
+ for (const p of paths) await this.run("add", "-f", "--", p).catch((e) => log34.debug(`force-capture failed: ${p}`, e));
15077
+ await this.run("commit", "--amend", "--no-edit", "-q", "--allow-empty").catch((e) => log34.debug("amend failed", e));
14892
15078
  }
14893
15079
  /** Commits on `ref`, oldest-first (canonical index space). */
14894
15080
  async log(ref) {
@@ -14948,7 +15134,7 @@ var ShadowRepo = class {
14948
15134
  await this.run("gc", "--auto", "-q").catch(() => {
14949
15135
  });
14950
15136
  } catch (e) {
14951
- log33.debug("checkpoint prune failed", e);
15137
+ log34.debug("checkpoint prune failed", e);
14952
15138
  }
14953
15139
  }
14954
15140
  };
@@ -15016,7 +15202,7 @@ var GitCheckpoints = class {
15016
15202
  use(sessionId) {
15017
15203
  if (sessionId === this.session) return;
15018
15204
  this.session = sessionId;
15019
- if (this.started) for (const r of this.repos) void r.point(this.ref()).catch((e) => log33.debug("re-point failed", e));
15205
+ if (this.started) for (const r of this.repos) void r.point(this.ref()).catch((e) => log34.debug("re-point failed", e));
15020
15206
  }
15021
15207
  async begin(label) {
15022
15208
  if (!await this.start()) return;
@@ -15036,7 +15222,7 @@ var GitCheckpoints = class {
15036
15222
  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
15037
15223
  `);
15038
15224
  } else {
15039
- log33.debug("checkpoint commit failed", e);
15225
+ log34.debug("checkpoint commit failed", e);
15040
15226
  survivors.push(this.repos[i]);
15041
15227
  survivorCaches.push(this.caches[i] ?? []);
15042
15228
  }
@@ -15071,7 +15257,7 @@ var GitCheckpoints = class {
15071
15257
  if (this.forced.has(abs)) continue;
15072
15258
  this.forced.add(abs);
15073
15259
  const i = this.repoIndexFor(abs);
15074
- if (i >= 0) await this.repos[i].amendForced([abs]).catch((e) => log33.debug("amendForced failed", e));
15260
+ if (i >= 0) await this.repos[i].amendForced([abs]).catch((e) => log34.debug("amendForced failed", e));
15075
15261
  }
15076
15262
  }
15077
15263
  };
@@ -16856,7 +17042,7 @@ import { spawnSync as spawnSync6 } from "child_process";
16856
17042
  import { writeFileSync as writeFileSync8, mkdirSync as mkdirSync10, readdirSync as readdirSync2, unlinkSync as unlinkSync3, chmodSync, existsSync as existsSync7 } from "fs";
16857
17043
  import { homedir as homedir8 } from "os";
16858
17044
  import { join as join14 } from "path";
16859
- var log34 = forComponent("os-sched");
17045
+ var log35 = forComponent("os-sched");
16860
17046
  var OsScheduler = class {
16861
17047
  options;
16862
17048
  constructor(options) {
@@ -16918,7 +17104,7 @@ var OsScheduler = class {
16918
17104
  }
16919
17105
  }
16920
17106
  } catch (e) {
16921
- log34.debug(`cancel ${id}`, e);
17107
+ log35.debug(`cancel ${id}`, e);
16922
17108
  }
16923
17109
  for (const f of [`${id}.json`, `${id}.sh`]) {
16924
17110
  try {
@@ -17036,7 +17222,7 @@ import { existsSync as existsSync8, mkdirSync as mkdirSync11, unlinkSync as unli
17036
17222
  import { homedir as homedir9 } from "os";
17037
17223
  import { join as join15 } from "path";
17038
17224
  init_platform();
17039
- var log35 = forComponent("remote-trigger");
17225
+ var log36 = forComponent("remote-trigger");
17040
17226
  var TRIGGER_DIR = () => join15(homedir9(), ".agent", "triggers");
17041
17227
  var sockPath = (sessionId, dir = TRIGGER_DIR()) => join15(dir, `${sessionId}.sock`);
17042
17228
  var TriggerServer = class {
@@ -17074,13 +17260,13 @@ var TriggerServer = class {
17074
17260
  conn.end(JSON.stringify({ ok: false, error: String(e) }) + "\n");
17075
17261
  }
17076
17262
  });
17077
- conn.on("error", (e) => log35.debug("trigger conn error", e));
17263
+ conn.on("error", (e) => log36.debug("trigger conn error", e));
17078
17264
  });
17079
- this.server.on("error", (e) => log35.debug("trigger server error", e));
17265
+ this.server.on("error", (e) => log36.debug("trigger server error", e));
17080
17266
  this.server.listen(p);
17081
17267
  this.path = p;
17082
17268
  } catch (e) {
17083
- log35.debug("trigger server unavailable", e);
17269
+ log36.debug("trigger server unavailable", e);
17084
17270
  }
17085
17271
  }
17086
17272
  /** Re-bind on /resume (the session id changed). */
@@ -17212,7 +17398,7 @@ var strike = C("9");
17212
17398
  var link = (text, url) => useColor ? `\x1B]8;;${url}\x1B\\${cyan(text)}\x1B]8;;\x1B\\` : `${text} (${url})`;
17213
17399
  var mdPalette = { bold, dim, cyan, italic, strike, green, link };
17214
17400
  var err = (s) => process.stderr.write(s);
17215
- var log36 = forComponent("cli");
17401
+ var log37 = forComponent("cli");
17216
17402
  var VERSION = (() => {
17217
17403
  try {
17218
17404
  return JSON.parse(readFileSync10(new URL("../package.json", import.meta.url), "utf8")).version ?? "?";
@@ -18055,7 +18241,7 @@ function turnCost(model, usage) {
18055
18241
  const priced = model.startsWith("claude-code/") ? `anthropic/${resolveModelAlias(model.slice("claude-code/".length))}` : model;
18056
18242
  return costOf(getModelInfo(priced)?.pricing, usage?.promptTokens ?? 0, usage?.completionTokens ?? 0, usage?.cacheCreationTokens ?? 0, usage?.cacheReadTokens ?? 0, priced);
18057
18243
  }
18058
- async function evaluateGoal(ai, condition, transcript, log37) {
18244
+ async function evaluateGoal(ai, condition, transcript, log38) {
18059
18245
  const recent = transcript.filter((m) => m.role === "assistant").slice(-8).map((m) => {
18060
18246
  const text = typeof m.content === "string" ? m.content : m.content.filter((p) => p.type === "text").map((p) => p.text).join(" ");
18061
18247
  return text.slice(0, 600);
@@ -18075,7 +18261,7 @@ ${recent}` }
18075
18261
  const match = r.content.match(/\{[\s\S]*\}/);
18076
18262
  if (match) return JSON.parse(match[0]);
18077
18263
  } catch (e) {
18078
- log37(dim(` (goal evaluator error: ${e?.message ?? e})
18264
+ log38(dim(` (goal evaluator error: ${e?.message ?? e})
18079
18265
  `));
18080
18266
  }
18081
18267
  return { met: false, reason: "evaluation unclear" };
@@ -18301,7 +18487,7 @@ function mcpAgentTools(mounted, opts) {
18301
18487
  return tools;
18302
18488
  }
18303
18489
  async function closeMcp(mounted) {
18304
- await Promise.all(mounted.map((m) => m.client.close().catch((e) => log36.debug("mcp close failed", e))));
18490
+ await Promise.all(mounted.map((m) => m.client.close().catch((e) => log37.debug("mcp close failed", e))));
18305
18491
  }
18306
18492
  var IMG_EXT = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp" };
18307
18493
  function mentionRefs(line) {
@@ -18360,7 +18546,7 @@ async function expandMentions(fs, line) {
18360
18546
  if (loaded.includes(ref) || missing.includes(ref)) continue;
18361
18547
  if (ref.includes(":") && mcpMentionResolver) {
18362
18548
  const body = await mcpMentionResolver(ref).catch((e) => {
18363
- log36.debug("mcp mention resolve failed", e);
18549
+ log37.debug("mcp mention resolve failed", e);
18364
18550
  return null;
18365
18551
  });
18366
18552
  if (body != null) {
@@ -18451,7 +18637,7 @@ async function runTurn(agent, store, session, task, cp, cwd = process.cwd(), sen
18451
18637
  try {
18452
18638
  store.save(session);
18453
18639
  } catch (ex) {
18454
- log36.debug("mid-turn session flush failed", ex);
18640
+ log37.debug("mid-turn session flush failed", ex);
18455
18641
  }
18456
18642
  }
18457
18643
  return origNotify(e);
@@ -18615,14 +18801,14 @@ var isCancelTeardown = (e) => {
18615
18801
  function installCancelGuards(mounted) {
18616
18802
  process.on("unhandledRejection", (e) => {
18617
18803
  if (isCancelTeardown(e)) {
18618
- log36.debug("suppressed unhandledRejection (cursor stream cancel)", e);
18804
+ log37.debug("suppressed unhandledRejection (cursor stream cancel)", e);
18619
18805
  return;
18620
18806
  }
18621
- log36.error("unhandledRejection", e);
18807
+ log37.error("unhandledRejection", e);
18622
18808
  });
18623
18809
  process.on("uncaughtException", (e) => {
18624
18810
  if (isCancelTeardown(e)) {
18625
- log36.debug("suppressed uncaughtException (cursor stream cancel)", e);
18811
+ log37.debug("suppressed uncaughtException (cursor stream cancel)", e);
18626
18812
  return;
18627
18813
  }
18628
18814
  console.error(e);
@@ -19049,7 +19235,7 @@ async function repl(args, ai, cfg, cwd) {
19049
19235
  try {
19050
19236
  mkdirSync12(dir, { recursive: true });
19051
19237
  } catch (e) {
19052
- log36.debug("paste image dir create failed", e);
19238
+ log37.debug("paste image dir create failed", e);
19053
19239
  }
19054
19240
  const img = grabClipboardImage(dir, String(Date.now()));
19055
19241
  return img ? { display: "Image", ref: "@" + img.path, path: img.path } : null;
@@ -19162,7 +19348,7 @@ Added entries are loadable now via the Skill/SlashCommand tools; removed ones ar
19162
19348
  mkdirSync12(join16(cwd, ".agent"), { recursive: true });
19163
19349
  appendFileSync(histPath, line + "\n");
19164
19350
  } catch (e) {
19165
- log36.debug("history write failed", e);
19351
+ log37.debug("history write failed", e);
19166
19352
  }
19167
19353
  };
19168
19354
  const ago = (t) => {
@@ -19234,7 +19420,7 @@ Added entries are loadable now via the Skill/SlashCommand tools; removed ones ar
19234
19420
  try {
19235
19421
  store.save(session);
19236
19422
  } catch (e) {
19237
- log36.debug("session save after rewind failed", e);
19423
+ log37.debug("session save after rewind failed", e);
19238
19424
  }
19239
19425
  err(green(" \u27F2 jumped back") + dim(` \u2014 ${face.transcript.length} message(s) kept; edit + resend
19240
19426
  `));
@@ -19269,7 +19455,7 @@ ${task}`;
19269
19455
  bangContext.length = 0;
19270
19456
  }
19271
19457
  const delta = await refreshCatalogs().catch((e) => {
19272
- log36.debug("catalog refresh failed", e);
19458
+ log37.debug("catalog refresh failed", e);
19273
19459
  return "";
19274
19460
  });
19275
19461
  if (delta) {
@@ -19532,7 +19718,7 @@ ${task}`;
19532
19718
  desc: "rescan skills/commands dirs and rebuild the system prompt (one cache miss) \u2014 picks up entries created mid-session",
19533
19719
  run: async () => {
19534
19720
  await refreshCatalogs().catch((e) => {
19535
- log36.debug("catalog refresh failed", e);
19721
+ log37.debug("catalog refresh failed", e);
19536
19722
  });
19537
19723
  face.reprepare();
19538
19724
  err(green(` \u2713 reloaded \u2014 ${skills.length} skill(s), ${cmds.length} command(s); system prompt rebuilds on next message
@@ -20035,7 +20221,7 @@ ${task}`;
20035
20221
  try {
20036
20222
  for (const def of (await loadAgents(fs2, d)).agents) if (!seen.has(def.name)) seen.set(def.name, { def, from: d });
20037
20223
  } catch (e) {
20038
- log36.debug(`loadAgents(${d}) failed`, e);
20224
+ log37.debug(`loadAgents(${d}) failed`, e);
20039
20225
  }
20040
20226
  }
20041
20227
  if (!seen.size) {
@@ -20123,7 +20309,7 @@ ${task}`;
20123
20309
  }
20124
20310
  if (idx >= 0) {
20125
20311
  const old = mounted.splice(idx, 1)[0];
20126
- await old.client.close().catch((e) => log36.debug("mcp close failed", e));
20312
+ await old.client.close().catch((e) => log37.debug("mcp close failed", e));
20127
20313
  }
20128
20314
  try {
20129
20315
  const m = await mountMcpServer(name, conf);
@@ -20151,7 +20337,7 @@ ${task}`;
20151
20337
  }
20152
20338
  const m = mounted.splice(idx, 1)[0];
20153
20339
  remountMcpTools();
20154
- await m.client.close().catch((e) => log36.debug("mcp close failed", e));
20340
+ await m.client.close().catch((e) => log37.debug("mcp close failed", e));
20155
20341
  err(dim(` removed "${name}"
20156
20342
  `));
20157
20343
  return;
@@ -20354,7 +20540,7 @@ ${task}`;
20354
20540
  try {
20355
20541
  return readdirSync4(join16(cwd, absDir.replace(/^\/+/, "")), { withFileTypes: true }).map((d) => ({ name: d.name, dir: d.isDirectory() }));
20356
20542
  } catch (e) {
20357
- log36.debug("completion readdir failed", absDir, e);
20543
+ log37.debug("completion readdir failed", absDir, e);
20358
20544
  return null;
20359
20545
  }
20360
20546
  };
@@ -20491,7 +20677,7 @@ ${out}
20491
20677
  return;
20492
20678
  }
20493
20679
  await refreshCatalogs().catch((e) => {
20494
- log36.debug("catalog refresh failed", e);
20680
+ log37.debug("catalog refresh failed", e);
20495
20681
  });
20496
20682
  const sk = skills.find((x) => x.name === name);
20497
20683
  if (sk) {
@@ -20609,7 +20795,7 @@ ${out}
20609
20795
  if (delivery || cut) dx?.parkCutDelivery(delivery, !!cut, heard);
20610
20796
  const cutReply = cut && cut.full.length - cut.heard.length > 40 ? cut : null;
20611
20797
  const noteP = replaces ? replaces.note : !cutReply ? Promise.resolve("") : (instinct ? instinct.bargeIntent(text).catch((e) => {
20612
- log36.warn(`barge intent failed \u2014 model decides: ${e.message}`);
20798
+ log37.warn(`barge intent failed \u2014 model decides: ${e.message}`);
20613
20799
  return void 0;
20614
20800
  }) : Promise.resolve(void 0)).then(async (intent) => interruptionNote(cutReply, intent) + ackedNote(await instinct?.ackFor(text)));
20615
20801
  const cutIn = replaces ? replaces.cutIn : interjectNote;
@@ -20971,12 +21157,15 @@ function makeSecondChannel(ai, reflexModel, opts) {
20971
21157
  const decider = leakDecider();
20972
21158
  const instinct = decider ? new Instinct({ decider }) : void 0;
20973
21159
  const model = process.env.AGENTX_CHANNEL_MODEL || reflexModel;
21160
+ const planner = new BackchannelPlanner(ai, { model });
20974
21161
  return new SecondChannel({
20975
21162
  // Banks in the user's language: generated once per language, cached + topped up across sessions (~/.agent/cache).
20976
21163
  banks: new ChannelBanks({ generate: fastBankGenerator(ai, model), cacheDir: join16(homedir10(), ".agent", "cache", "channel-banks") }),
20977
21164
  decide: instinct ? (heard, state) => instinct.channel(heard, state) : void 0,
20978
21165
  generate: fastLineGenerator(ai, model),
20979
- onEvent: (e) => log36.debug(`channel ${e.trigger}: rule=${e.rule} choice=${e.choice ?? "-"} \u2192 ${e.acted}${e.text ? ` "${e.text}"` : ""} ${e.ms}ms`),
21166
+ // Anticipated backchannels (off until the offline replay proves them — tests/voice-bc-anticipate-eval.ts)
21167
+ ...process.env.AGENTX_CHANNEL_ANTICIPATE === "1" ? { anticipate: true, plan: (c, s) => planner.plan(c, s) } : {},
21168
+ 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`),
20980
21169
  ...opts
20981
21170
  });
20982
21171
  }
@@ -21038,7 +21227,7 @@ async function duplexServe(args, ai, cfg, cwd) {
21038
21227
  try {
21039
21228
  store.save(session);
21040
21229
  } catch (e) {
21041
- log36.warn("duplex session save failed", e);
21230
+ log37.warn("duplex session save failed", e);
21042
21231
  }
21043
21232
  };
21044
21233
  out({ type: "ready", sessionId: session.meta.id, reflexModel: dx.options.reflexModel, actModel: dx.options.actModel, ...notes.length ? { notes } : {} });
@@ -21076,7 +21265,7 @@ async function duplexServe(args, ai, cfg, cwd) {
21076
21265
  done2();
21077
21266
  out({ type: "turn_done", id, finishReason: res?.finishReason ?? "error", ...why ? { error: why } : {} });
21078
21267
  }).catch((e) => {
21079
- log36.error("duplex turn failed", e);
21268
+ log37.error("duplex turn failed", e);
21080
21269
  done2();
21081
21270
  out({ type: "turn_done", id, finishReason: "error", error: String(e?.message ?? e) });
21082
21271
  });