@livx.cc/agentx 0.99.117 → 0.99.119

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { a as AgentOptions, H as Hooks, p as RunResult, f as Decider, A as Agent, i as PermissionPolicy, G as Gender, L as LineKind, C as ChannelMode, q as SecondChannelOptions, S as SecondChannel } from './secondChannel-BCEr_QyM.js';
2
- export { b as ChatFragment, c as Classification, d as Classifier, e as CompactResult, D as DEFAULT_MUTATING, g as Decision, M as ModelRouter, h as ModelRouterOptions, P as PermissionOptions, j as PermissionRule, k as PreToolUseDecision, R as ReasoningEffort, l as RecordingHooks, m as RecordingLifecycle, n as RouteDecision, o as RouterState, T as Tier, r as ToolUse, s as ToolUseMeta, t as claudeCodeDisallowedTools, u as composeHooks, v as estimateTokens, w as jevAvailable, x as jevClassifier, y as pickTier, z as planMode, B as reasoningToChatFragment } from './secondChannel-BCEr_QyM.js';
1
+ import { a as AgentOptions, H as Hooks, p as RunResult, f as Decider, A as Agent, i as PermissionPolicy, G as Gender, L as LineKind, C as ChannelMode, q as SecondChannelOptions, S as SecondChannel } from './secondChannel-DepBV0Zv.js';
2
+ export { b as ChatFragment, c as Classification, d as Classifier, e as CompactResult, D as DEFAULT_MUTATING, g as Decision, M as ModelRouter, h as ModelRouterOptions, P as PermissionOptions, j as PermissionRule, k as PreToolUseDecision, R as ReasoningEffort, l as RecordingHooks, m as RecordingLifecycle, n as RouteDecision, o as RouterState, T as Tier, r as ToolUse, s as ToolUseMeta, t as claudeCodeDisallowedTools, u as composeHooks, v as estimateTokens, w as jevAvailable, x as jevClassifier, y as pickTier, z as planMode, B as reasoningToChatFragment } from './secondChannel-DepBV0Zv.js';
3
3
  export { MODEL_ALIASES, ModelSwitch, ResolveModelSwitchOpts, latestClaude, modelShortLabel, resolveModelAlias, resolveModelSwitch } from './models.js';
4
4
  import { IFilesystem, FileMetadata } from '@livx.cc/wcli/core';
5
5
  export { CommandExecutor, FileMetadata, IFilesystem, IndexedDbFilesystem, MemFilesystem, registerHeadlessCommands } from '@livx.cc/wcli/core';
@@ -1666,15 +1666,40 @@ interface MainEvent {
1666
1666
  name?: string;
1667
1667
  args?: unknown;
1668
1668
  }
1669
+ /** One turn's timeline, ms from the user's last word (the STT's word clock when the host passes it, else the final's
1670
+ * arrival). A stage that did not happen is absent. */
1671
+ interface TurnTimeline {
1672
+ /** The STT's final reached the call (STT latency + silence endpoint + the engine's merge hold). */
1673
+ final?: number;
1674
+ /** TurnGate sent the turn to MAIN (its own hold for a mid-phrase line included). */
1675
+ sent?: number;
1676
+ /** The fill channel chose its first line for this turn (bridge/filler/progress) — handed to the host, not yet heard. */
1677
+ fill?: number;
1678
+ fillKind?: string;
1679
+ /** That line's audio started (hostAudio: the host's player; else = fill). */
1680
+ fillAudio?: number;
1681
+ /** MAIN's first event (text or tool). */
1682
+ mainFirst?: number;
1683
+ /** MAIN's first text token. */
1684
+ mainText?: number;
1685
+ /** MAIN's first audio started (the host's player). */
1686
+ mainAudio?: number;
1687
+ /** false: the host gave no last-word time — the anchor is the final's arrival. */
1688
+ lastWord: boolean;
1689
+ /** How the turn ended: answered = MAIN audio played · cut = barged · replaced = a fuller re-final took it · silent = no MAIN audio. */
1690
+ end?: 'answered' | 'cut' | 'replaced' | 'silent';
1691
+ }
1669
1692
  interface CallMetric {
1670
- name: 'first_sound' | 'first_word' | 'turn' | 'drop' | 'hold' | 'fill' | 'lang';
1693
+ name: 'first_sound' | 'first_word' | 'turn' | 'drop' | 'hold' | 'fill' | 'lang' | 'timeline';
1671
1694
  id?: string;
1672
- /** ms from the user's final (the first final of a held/merged turn) */
1695
+ /** ms from the user's last word (see TurnTimeline) */
1673
1696
  ms?: number;
1674
1697
  by?: 'fill' | 'progress' | 'main';
1675
1698
  kind?: string;
1676
1699
  text?: string;
1677
1700
  lang?: string | null;
1701
+ /** name 'timeline': the turn's stages. */
1702
+ timeline?: TurnTimeline;
1678
1703
  }
1679
1704
  declare class VoiceCallOptions {
1680
1705
  /** Host runs MAIN with exactly `text` (a replace: roll the `replaces` turn back first). */
@@ -1707,6 +1732,9 @@ declare class VoiceCallOptions {
1707
1732
  /** Extra SecondChannel options (tests, tuning). speak/show/cancel are always VoiceCall's own. */
1708
1733
  channel: Partial<SecondChannelOptions>;
1709
1734
  onMetric: (m: CallMetric) => void;
1735
+ /** The host reports when a fill line's audio REALLY starts (heard()). Off: a line handed to `speak` counts as heard —
1736
+ * right only for a host whose speak() plays at once (a relay host would count lines its player later dropped). */
1737
+ hostAudio: boolean;
1710
1738
  now: () => number;
1711
1739
  /** An aside (aside()) needs this long since the last one — a cue answered every time sounds scripted. */
1712
1740
  asideGapMs: number;
@@ -1722,6 +1750,8 @@ declare class VoiceCall {
1722
1750
  private lastAside;
1723
1751
  private lastAudibleAt;
1724
1752
  private firstFinalAt;
1753
+ private lastWordAt;
1754
+ private earlyHeard;
1725
1755
  private progressTimer;
1726
1756
  private ended;
1727
1757
  constructor(options?: Partial<VoiceCallOptions> | undefined);
@@ -1738,14 +1768,25 @@ declare class VoiceCall {
1738
1768
  setLanguage(tag: string): void;
1739
1769
  /** STT partial (the whole partial). */
1740
1770
  interim(text: string, _atMs?: number, aec?: boolean): void;
1741
- /** STT final → TurnGate. */
1742
- final(text: string, sttId?: string): void;
1771
+ /** STT final → TurnGate. `lastWordAgoMs`: how long ago the user's last word of it ended (the STT's word clock,
1772
+ * bod-voice UtteranceMeta.lastWordAgoMs) — the turn's latency anchor; absent = the final's arrival. `lang`: the
1773
+ * language the STT identified for it (UtteranceMeta.language) — what the turn's words follow when they alone can't tell
1774
+ * (a short English line after a Hebrew one left the fill channel on Hebrew: its bridge was refused, the turn silent). */
1775
+ final(text: string, sttId?: string, o?: {
1776
+ lastWordAgoMs?: number;
1777
+ lang?: string;
1778
+ }): void;
1779
+ private finalAt;
1780
+ private sttLang;
1781
+ /** The host's player started a line's audio now (or in `inMs`): `fill`/`progress` = the fill channel's, `main` = MAIN's
1782
+ * reply (as main({type:'audio'})). With hostAudio this is what first_sound counts. */
1783
+ heard(by: 'fill' | 'progress' | 'main', inMs?: number): void;
1743
1784
  /** A v2 host that decides turns itself: the user's line already went to MAIN (no TurnGate, no sendTurn) — the fill
1744
1785
  * channel follows its language and covers the wait. */
1745
1786
  hostTurn(text: string): void;
1746
1787
  pause(ms: number): void;
1747
- /** MAIN's stream. */
1748
- main(ev: MainEvent): void;
1788
+ /** MAIN's stream. (`inMs`: an audio event's start is that far ahead — the host player's queue.) */
1789
+ main(ev: MainEvent, inMs?: number): void;
1749
1790
  /** The user barged in (hard): cut fill + MAIN audio (the host cuts its own), abort a running MAIN turn. */
1750
1791
  barge(): void;
1751
1792
  /** Soft barge-in: keep (a listener cue — no turn) or cut (→ barge()). */
@@ -1768,6 +1809,9 @@ declare class VoiceCall {
1768
1809
  /** Every fill/progress line passes here: only in the turn's known language, never after the call ended. */
1769
1810
  private say;
1770
1811
  private sounded;
1812
+ /** The turn is over (answered + audible, replaced, the next turn, the call's end): its timeline, once. A turn whose MAIN
1813
+ * never became audible still reports first_word — with no ms and why (cut / replaced / silent) — so every turn has one. */
1814
+ private close;
1771
1815
  private onTool;
1772
1816
  private clearProgress;
1773
1817
  }
package/dist/index.js CHANGED
@@ -5191,11 +5191,12 @@ var Agent = class _Agent {
5191
5191
  let emptyNudged = false;
5192
5192
  let closureNudged = false;
5193
5193
  let lastProgressAt = start;
5194
+ const turnUser = this.transcript[this.transcript.length - 1];
5194
5195
  const kill = (finishReason) => {
5195
5196
  log8.warn(`kill-switch: ${finishReason} (steps=${steps}, tokens=${usage.totalTokens}, budgetTokens=${Math.round(spent)}, ms=${Date.now() - start - this.parkedMs}${finishReason === "timeout" ? `, idle=${Date.now() - lastProgressAt - this.parkedMs}ms` : ""}${this.parkedMs ? ` +${this.parkedMs} parked` : ""})`);
5196
5197
  this.jobs.killAll(`run ended: ${finishReason}`, true);
5197
5198
  const claims = this.survivingClaims();
5198
- return { text: lastAssistantText(this.transcript), steps, finishReason, messages: this.transcript, usage, usageEstimated, ...finishReason === "budget" ? { budget: { spent: Math.round(spent), limit: o.maxTokens, stepPrompt } } : {}, ...claims.length ? { fabricatedToolClaims: claims } : {} };
5199
+ return { text: lastAssistantText(this.transcript, turnUser), steps, finishReason, messages: this.transcript, usage, usageEstimated, ...finishReason === "budget" ? { budget: { spent: Math.round(spent), limit: o.maxTokens, stepPrompt } } : {}, ...claims.length ? { fabricatedToolClaims: claims } : {} };
5199
5200
  };
5200
5201
  const overBudget = () => !!o.maxTokens && spent >= o.maxTokens;
5201
5202
  const canNudge = () => steps < o.maxSteps && !overBudget();
@@ -6055,8 +6056,9 @@ function summarize(messages, focus) {
6055
6056
  }
6056
6057
  return lines.join("\n");
6057
6058
  }
6058
- function lastAssistantText(messages) {
6059
+ function lastAssistantText(messages, since) {
6059
6060
  for (let i = messages.length - 1; i >= 0; i--) {
6061
+ if (since && messages[i] === since) return "";
6060
6062
  if (messages[i].role === "assistant") return contentText(messages[i].content);
6061
6063
  }
6062
6064
  return "";
@@ -13102,11 +13104,26 @@ var SecondChannel = class {
13102
13104
  }
13103
13105
  /** Hard gates every line passes, whoever proposed it. */
13104
13106
  allowed(k) {
13105
- if (!this.o.kinds.includes(k)) return false;
13106
- 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;
13107
- if (k === "backchannel") return this.o.aec && this.userSpeaking && this.main === "idle" && !this.capped();
13108
- if (k === "filler") return !this.userSpeaking && this.main === "idle";
13109
- return (this.main === "queued" || this.main === "thinking") && !this.bridged;
13107
+ return !this.blocked(k);
13108
+ }
13109
+ /** Why a line of kind `k` can't play now ('' = it can) — the call's ledger names a missing bridge's cause. */
13110
+ blocked(k) {
13111
+ if (!this.o.kinds.includes(k)) return "kind off";
13112
+ if (this.audioLocked) return `main audio (${this.main})`;
13113
+ if (this.inflight) return "a line still playing";
13114
+ if (this.o.now() - this.lastAt < this.o.minGapMs) return `${Math.round(this.o.now() - this.lastAt)}ms since the last line`;
13115
+ if (k === this.lastKind && (k !== "backchannel" || this.o.now() - this.lastAt < this.o.backchannelGapMs)) return "same kind as the last line";
13116
+ if (k === "backchannel") return this.o.aec && this.userSpeaking && this.main === "idle" && !this.capped() ? "" : "not a backchannel moment";
13117
+ if (k === "filler") return !this.userSpeaking && this.main === "idle" ? "" : "not a filler moment";
13118
+ return this.bridged ? "bridged already" : this.main === "queued" || this.main === "thinking" ? "" : `main ${this.main}`;
13119
+ }
13120
+ /** Why this turn gets no bridge by rule ('' = it may) — language, a quick/phatic turn, or blocked(). */
13121
+ bridgeBlock() {
13122
+ const w = words2(this.heard);
13123
+ if (!this.lang) return "language unknown";
13124
+ if (w.length <= 3 || w.length <= 6 && isPhatic(this.heard, this.bank)) return w.length <= 3 ? "quick turn" : "phatic";
13125
+ if (w.length < this.o.bridgeMinWords) return `under ${this.o.bridgeMinWords} words`;
13126
+ return this.blocked("bridge");
13110
13127
  }
13111
13128
  bcTimes = [];
13112
13129
  capped() {
@@ -13153,7 +13170,10 @@ var SecondChannel = class {
13153
13170
  return breath || /[,;:…]$/.test(this.heard);
13154
13171
  }
13155
13172
  async consider(trigger, pauseMs, breath = false, cue = null) {
13156
- if (this.busy) return;
13173
+ if (this.busy) {
13174
+ if (trigger === "working") this.o.onEvent({ trigger, rule: "silence", ms: 0, acted: "silence", reason: "deciding another line" });
13175
+ return;
13176
+ }
13157
13177
  const n0 = words2(this.heard).length;
13158
13178
  this.decidedAt = n0;
13159
13179
  if (!(this.o.anticipate && this.plan && trigger === "interim")) cue = null;
@@ -13163,7 +13183,10 @@ var SecondChannel = class {
13163
13183
  log28.info("chatty mode without echo cancellation \u2014 normal timing (our lines would re-trigger the mic)");
13164
13184
  }
13165
13185
  const rule = this.rule(trigger, pauseMs, breath, cue);
13166
- if (rule === "silence") return;
13186
+ if (rule === "silence") {
13187
+ if (trigger === "working") this.o.onEvent({ trigger, rule, ms: 0, acted: "silence", reason: this.bridgeBlock() || "rule" });
13188
+ return;
13189
+ }
13167
13190
  this.busy = true;
13168
13191
  const gen = this.gen, t0 = this.o.now();
13169
13192
  const ev = (e) => this.o.onEvent({ trigger, rule, ms: Math.round(this.o.now() - t0), ...cue ? { cue: cue.line } : {}, ...e });
@@ -13357,6 +13380,9 @@ var VoiceCallOptions = class {
13357
13380
  channel = {};
13358
13381
  onMetric = () => {
13359
13382
  };
13383
+ /** The host reports when a fill line's audio REALLY starts (heard()). Off: a line handed to `speak` counts as heard —
13384
+ * right only for a host whose speak() plays at once (a relay host would count lines its player later dropped). */
13385
+ hostAudio = false;
13360
13386
  now = () => performance.now();
13361
13387
  /** An aside (aside()) needs this long since the last one — a cue answered every time sounds scripted. */
13362
13388
  asideGapMs = 8e3;
@@ -13376,11 +13402,17 @@ var VoiceCall = class {
13376
13402
  } : {},
13377
13403
  mode: o.mode,
13378
13404
  aec: o.aec,
13379
- bridgeDelayMs: 700,
13405
+ // v3 §3: first sound <1.2s p50 from the last word — the endpoint takes ~0.6s and the bridge's TTS ~0.2s, so the bridge
13406
+ // goes 300ms after the turn if MAIN is still silent (it always is that early: warm MAIN's first token is ≥1s), no veto
13407
+ bridgeDelayMs: 300,
13380
13408
  instantBridge: true,
13381
- // v3 §3: first sound <1.2s p50 — a bridge at 700ms if MAIN is still silent, no veto, no wait
13382
13409
  ...this.lang ? { lang: this.lang } : {},
13383
13410
  ...o.channel,
13411
+ // the ledger: a turn's bridge moment that stayed silent, and why (its wait then has no first sound until MAIN speaks)
13412
+ onEvent: (e) => {
13413
+ if (e.trigger === "working" && e.acted !== "spoken") o.onMetric({ name: "drop", ...this.cur ? { id: this.cur.id } : {}, kind: `no bridge: ${e.reason ?? e.acted}`, ...e.text ? { text: e.text } : {} });
13414
+ o.channel.onEvent?.(e);
13415
+ },
13384
13416
  speak: (text, kind) => this.say(text, kind),
13385
13417
  show: (text, kind) => o.show(text, kind),
13386
13418
  cancel: () => o.cancel(),
@@ -13407,6 +13439,10 @@ var VoiceCall = class {
13407
13439
  lastAudibleAt = -Infinity;
13408
13440
  firstFinalAt = null;
13409
13441
  // the first final of the turn being formed (hold/merge)
13442
+ lastWordAt = null;
13443
+ // the user's last word before the turn being formed (its latest final's)
13444
+ earlyHeard = null;
13445
+ // a fill line heard while that turn was still held
13410
13446
  progressTimer = null;
13411
13447
  ended = false;
13412
13448
  get o() {
@@ -13435,12 +13471,37 @@ var VoiceCall = class {
13435
13471
  this.follow(text);
13436
13472
  this.fill.onInterim(text);
13437
13473
  }
13438
- /** STT final → TurnGate. */
13439
- final(text, sttId) {
13474
+ /** STT final → TurnGate. `lastWordAgoMs`: how long ago the user's last word of it ended (the STT's word clock,
13475
+ * bod-voice UtteranceMeta.lastWordAgoMs) — the turn's latency anchor; absent = the final's arrival. `lang`: the
13476
+ * language the STT identified for it (UtteranceMeta.language) — what the turn's words follow when they alone can't tell
13477
+ * (a short English line after a Hebrew one left the fill channel on Hebrew: its bridge was refused, the turn silent). */
13478
+ final(text, sttId, o = {}) {
13440
13479
  if (this.ended) return;
13441
- this.firstFinalAt ??= this.o.now();
13480
+ const now3 = this.o.now(), { lastWordAgoMs } = o;
13481
+ this.firstFinalAt ??= now3;
13482
+ this.sttLang = o.lang ? primaryLang(o.lang) : null;
13483
+ this.lastWordAt = typeof lastWordAgoMs === "number" && lastWordAgoMs >= 0 ? now3 - lastWordAgoMs : null;
13484
+ this.finalAt = now3;
13442
13485
  this.gate.final(text, sttId);
13443
13486
  }
13487
+ finalAt = 0;
13488
+ sttLang = null;
13489
+ // the STT's language for the final being followed
13490
+ /** The host's player started a line's audio now (or in `inMs`): `fill`/`progress` = the fill channel's, `main` = MAIN's
13491
+ * reply (as main({type:'audio'})). With hostAudio this is what first_sound counts. */
13492
+ heard(by, inMs = 0) {
13493
+ if (this.ended) return;
13494
+ if (by === "main") return this.main({ type: "audio", ...this.cur ? { id: this.cur.id } : {} }, inMs);
13495
+ const at = this.o.now() + Math.max(0, inMs);
13496
+ if (this.gate.holding || !this.cur || this.cur.closed) {
13497
+ this.earlyHeard ??= { at, by };
13498
+ return;
13499
+ }
13500
+ this.lastAudibleAt = at;
13501
+ const c = this.cur;
13502
+ c.marks.fillAudio ??= at;
13503
+ this.sounded(c, by, at);
13504
+ }
13444
13505
  /** A v2 host that decides turns itself: the user's line already went to MAIN (no TurnGate, no sendTurn) — the fill
13445
13506
  * channel follows its language and covers the wait. */
13446
13507
  hostTurn(text) {
@@ -13451,8 +13512,8 @@ var VoiceCall = class {
13451
13512
  pause(ms) {
13452
13513
  if (!this.ended) this.fill.onPause(ms);
13453
13514
  }
13454
- /** MAIN's stream. */
13455
- main(ev) {
13515
+ /** MAIN's stream. (`inMs`: an audio event's start is that far ahead — the host player's queue.) */
13516
+ main(ev, inMs = 0) {
13456
13517
  const c = this.cur;
13457
13518
  if (!c || ev.id && ev.id !== c.id) return;
13458
13519
  switch (ev.type) {
@@ -13461,6 +13522,8 @@ var VoiceCall = class {
13461
13522
  return;
13462
13523
  case "text":
13463
13524
  if (!ev.text) return;
13525
+ c.marks.mainFirst ??= this.o.now();
13526
+ c.marks.mainText ??= this.o.now();
13464
13527
  if (!c.streamed) {
13465
13528
  c.streamed = true;
13466
13529
  this.gate.replyStarted(c.id);
@@ -13470,25 +13533,32 @@ var VoiceCall = class {
13470
13533
  this.fill.onMainTokens(ev.text);
13471
13534
  return;
13472
13535
  case "tool":
13536
+ c.marks.mainFirst ??= this.o.now();
13473
13537
  this.gate.replyStarted(c.id);
13474
13538
  void this.onTool(c, ev.name ?? "", ev.args);
13475
13539
  return;
13476
13540
  case "tool_done":
13477
13541
  return;
13478
- case "audio":
13479
- this.lastAudibleAt = this.o.now();
13542
+ case "audio": {
13543
+ const at = this.o.now() + Math.max(0, inMs);
13544
+ this.lastAudibleAt = at;
13480
13545
  this.fill.onAgentState("first_audio");
13481
- this.sounded(c, "main");
13546
+ this.sounded(c, "main", at);
13482
13547
  if (!c.worded) {
13483
13548
  c.worded = true;
13484
- this.o.onMetric({ name: "first_word", id: c.id, ms: Math.round(this.o.now() - c.at), by: "main" });
13549
+ c.marks.mainAudio = at;
13550
+ this.o.onMetric({ name: "first_word", id: c.id, ms: Math.round(at - c.at), by: "main" });
13485
13551
  }
13552
+ if (c.done) this.close(c, "answered");
13486
13553
  return;
13554
+ }
13487
13555
  case "done":
13488
13556
  case "error":
13489
13557
  c.running = false;
13558
+ c.done = true;
13490
13559
  this.clearProgress();
13491
13560
  this.fill.onAgentState("idle");
13561
+ if (c.worded) this.close(c, "answered");
13492
13562
  return;
13493
13563
  }
13494
13564
  }
@@ -13505,6 +13575,7 @@ var VoiceCall = class {
13505
13575
  this.cur.running = false;
13506
13576
  this.o.abortTurn(this.cur.id);
13507
13577
  }
13578
+ if (this.cur && !this.cur.worded) this.cur.end ??= "cut";
13508
13579
  }
13509
13580
  /** Soft barge-in: keep (a listener cue — no turn) or cut (→ barge()). */
13510
13581
  async overlap(text) {
@@ -13534,6 +13605,7 @@ var VoiceCall = class {
13534
13605
  return this.fill.drain();
13535
13606
  }
13536
13607
  end() {
13608
+ if (this.cur) this.close(this.cur);
13537
13609
  this.ended = true;
13538
13610
  this.gate.end();
13539
13611
  this.clearProgress();
@@ -13542,24 +13614,39 @@ var VoiceCall = class {
13542
13614
  /** Follow the user's language from their own words: a confident detection, else the current language if the words
13543
13615
  * fit it, else unknown (silent). Pinned into the channel so its own detection never diverges. */
13544
13616
  follow(text) {
13545
- const l = detectLanguage(text) ?? (this.lang && fitsLanguage(text, this.lang) ? this.lang : null);
13617
+ const fits = (l2) => l2 && fitsLanguage(text, l2) ? l2 : null;
13618
+ const l = detectLanguage(text) ?? fits(this.sttLang) ?? fits(this.lang);
13546
13619
  if (l !== this.lang) this.o.onMetric({ name: "lang", lang: l, text });
13547
13620
  this.lang = l;
13548
13621
  if (l) this.fill.setLanguage(l, true);
13549
13622
  }
13550
13623
  onTurn(t) {
13551
- const at = this.firstFinalAt ?? this.o.now();
13624
+ const now3 = this.o.now(), lastWord = this.lastWordAt !== null;
13625
+ const at = this.lastWordAt ?? this.firstFinalAt ?? now3;
13626
+ const finalAt = this.finalAt || now3;
13552
13627
  this.firstFinalAt = null;
13628
+ this.lastWordAt = null;
13629
+ if (this.cur) this.close(this.cur, t.replaces === this.cur.id ? "replaced" : void 0);
13553
13630
  this.follow(t.text);
13554
13631
  this.clearProgress();
13555
- this.cur = { id: t.id, text: t.text, at, running: true, streamed: false, sounded: false, worded: false };
13632
+ this.cur = { id: t.id, text: t.text, at, finalAt, lastWord, running: true, streamed: false, sounded: false, worded: false, done: false, marks: { sent: now3 }, closed: false };
13633
+ const early = this.earlyHeard;
13634
+ this.earlyHeard = null;
13635
+ if (early && early.at >= at) {
13636
+ this.cur.marks.fillAudio = early.at;
13637
+ this.sounded(this.cur, early.by, early.at);
13638
+ }
13556
13639
  this.progress.turn();
13557
13640
  this.o.onMetric({ name: "turn", id: t.id, kind: t.kind, text: t.text });
13558
13641
  this.o.sendTurn(t.text, { id: t.id, ...t.replaces ? { replaces: t.replaces } : {} });
13559
13642
  if (t.kind === "new") this.fill.onUserTurn(t.text);
13560
13643
  }
13561
13644
  onDrop(text, why) {
13562
- if (!this.gate.holding) this.firstFinalAt = null;
13645
+ if (!this.gate.holding) {
13646
+ this.firstFinalAt = null;
13647
+ this.lastWordAt = null;
13648
+ this.earlyHeard = null;
13649
+ }
13563
13650
  this.o.onMetric({ name: "drop", kind: why, text });
13564
13651
  }
13565
13652
  /** Every fill/progress line passes here: only in the turn's known language, never after the call ended. */
@@ -13573,16 +13660,48 @@ var VoiceCall = class {
13573
13660
  return false;
13574
13661
  }
13575
13662
  if (r === false) return false;
13576
- this.lastAudibleAt = this.o.now();
13577
13663
  this.o.onMetric({ name: "fill", kind, text, lang: this.lang });
13578
- if (this.cur && (kind === "bridge" || kind === "progress")) this.sounded(this.cur, kind === "progress" ? "progress" : "fill");
13579
- else if (this.cur && this.cur.running && !this.cur.sounded && kind === "filler") this.sounded(this.cur, "fill");
13664
+ const c = this.cur && !this.cur.closed ? this.cur : void 0;
13665
+ const forTurn = !!c && (kind === "bridge" || kind === "progress" || kind === "filler" && c.running && !c.sounded);
13666
+ if (c && forTurn && c.marks.fill === void 0) {
13667
+ c.marks.fill = this.o.now();
13668
+ c.fillKind = kind;
13669
+ }
13670
+ if (this.o.hostAudio) return r;
13671
+ this.lastAudibleAt = this.o.now();
13672
+ if (c && forTurn) {
13673
+ c.marks.fillAudio ??= this.o.now();
13674
+ this.sounded(c, kind === "progress" ? "progress" : "fill");
13675
+ }
13580
13676
  return r;
13581
13677
  }
13582
- sounded(c, by) {
13678
+ sounded(c, by, at = this.o.now()) {
13583
13679
  if (c.sounded) return;
13584
13680
  c.sounded = true;
13585
- this.o.onMetric({ name: "first_sound", id: c.id, ms: Math.round(this.o.now() - c.at), by });
13681
+ this.o.onMetric({ name: "first_sound", id: c.id, ms: Math.round(at - c.at), by });
13682
+ }
13683
+ /** The turn is over (answered + audible, replaced, the next turn, the call's end): its timeline, once. A turn whose MAIN
13684
+ * never became audible still reports first_word — with no ms and why (cut / replaced / silent) — so every turn has one. */
13685
+ close(c, end) {
13686
+ if (c.closed) return;
13687
+ c.closed = true;
13688
+ c.end = c.worded ? "answered" : end ?? c.end ?? "silent";
13689
+ if (!c.worded) this.o.onMetric({ name: "first_word", id: c.id, by: "main", kind: c.end });
13690
+ const rel = (t) => t === void 0 ? void 0 : Math.round(t - c.at);
13691
+ const m = c.marks, tl = {
13692
+ final: rel(c.finalAt),
13693
+ sent: rel(m.sent),
13694
+ fill: rel(m.fill),
13695
+ ...c.fillKind ? { fillKind: c.fillKind } : {},
13696
+ fillAudio: rel(m.fillAudio),
13697
+ mainFirst: rel(m.mainFirst),
13698
+ mainText: rel(m.mainText),
13699
+ mainAudio: rel(m.mainAudio),
13700
+ lastWord: c.lastWord,
13701
+ end: c.end
13702
+ };
13703
+ for (const k of Object.keys(tl)) if (tl[k] === void 0) delete tl[k];
13704
+ this.o.onMetric({ name: "timeline", id: c.id, text: c.text, timeline: tl });
13586
13705
  }
13587
13706
  async onTool(c, name, args) {
13588
13707
  const lang = this.lang;