@livx.cc/agentx 0.99.56 → 0.99.58
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.d.ts +7 -0
- package/dist/cli.js +2078 -302
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +167 -29
- package/dist/index.js +1476 -186
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -5159,6 +5159,10 @@ var Agent = class _Agent {
|
|
|
5159
5159
|
args = tc.function.arguments;
|
|
5160
5160
|
earlyError ??= `Error: invalid JSON arguments for ${tc.function.name}: ${String(e)}`;
|
|
5161
5161
|
}
|
|
5162
|
+
if (!earlyError && tool && args && typeof args === "object") {
|
|
5163
|
+
const missing = (tool.parameters?.required ?? []).filter((k) => args[k] == null);
|
|
5164
|
+
if (missing.length) earlyError = `Error: ${tc.function.name} is missing required argument(s): ${missing.join(", ")} (got: ${Object.keys(args).join(", ") || "none"}). Re-call with the exact parameter names.`;
|
|
5165
|
+
}
|
|
5162
5166
|
const hooks = this.activeHooks;
|
|
5163
5167
|
const call = { name: tc.function.name, args };
|
|
5164
5168
|
const meta = { id: tc.id };
|
|
@@ -6561,6 +6565,130 @@ var EmotionStream = class {
|
|
|
6561
6565
|
}
|
|
6562
6566
|
};
|
|
6563
6567
|
|
|
6568
|
+
// node_modules/@bod.ee/voice/src/core/endpointing.ts
|
|
6569
|
+
var INCOMPLETE_ENDINGS = /* @__PURE__ */ new Set([
|
|
6570
|
+
// Conjunctions
|
|
6571
|
+
"and",
|
|
6572
|
+
"but",
|
|
6573
|
+
"or",
|
|
6574
|
+
"so",
|
|
6575
|
+
"because",
|
|
6576
|
+
"although",
|
|
6577
|
+
"though",
|
|
6578
|
+
"while",
|
|
6579
|
+
"since",
|
|
6580
|
+
"unless",
|
|
6581
|
+
"if",
|
|
6582
|
+
"when",
|
|
6583
|
+
"where",
|
|
6584
|
+
// Prepositions
|
|
6585
|
+
"to",
|
|
6586
|
+
"for",
|
|
6587
|
+
"with",
|
|
6588
|
+
"about",
|
|
6589
|
+
"of",
|
|
6590
|
+
"at",
|
|
6591
|
+
"in",
|
|
6592
|
+
"on",
|
|
6593
|
+
"from",
|
|
6594
|
+
"by",
|
|
6595
|
+
"into",
|
|
6596
|
+
"through",
|
|
6597
|
+
// Articles / determiners
|
|
6598
|
+
"the",
|
|
6599
|
+
"a",
|
|
6600
|
+
"an",
|
|
6601
|
+
"this",
|
|
6602
|
+
"that",
|
|
6603
|
+
"these",
|
|
6604
|
+
"those",
|
|
6605
|
+
"my",
|
|
6606
|
+
"your",
|
|
6607
|
+
"his",
|
|
6608
|
+
"her",
|
|
6609
|
+
"our",
|
|
6610
|
+
"their",
|
|
6611
|
+
// Bare pronouns ("…as I asked it" — the live case above)
|
|
6612
|
+
"i",
|
|
6613
|
+
"you",
|
|
6614
|
+
"we",
|
|
6615
|
+
"they",
|
|
6616
|
+
"he",
|
|
6617
|
+
"she",
|
|
6618
|
+
"it",
|
|
6619
|
+
// Fillers (thinking)
|
|
6620
|
+
"um",
|
|
6621
|
+
"uh",
|
|
6622
|
+
"like",
|
|
6623
|
+
"well"
|
|
6624
|
+
]);
|
|
6625
|
+
var CONTINUATION_MARKERS = [
|
|
6626
|
+
"you know",
|
|
6627
|
+
"i mean",
|
|
6628
|
+
"the thing is",
|
|
6629
|
+
"let me think",
|
|
6630
|
+
"first of all",
|
|
6631
|
+
"on one hand",
|
|
6632
|
+
"for example"
|
|
6633
|
+
];
|
|
6634
|
+
var COMPLETE_ENDINGS = /* @__PURE__ */ new Set([
|
|
6635
|
+
"yeah",
|
|
6636
|
+
"yes",
|
|
6637
|
+
"no",
|
|
6638
|
+
"nah",
|
|
6639
|
+
"okay",
|
|
6640
|
+
"ok",
|
|
6641
|
+
"sure",
|
|
6642
|
+
"right",
|
|
6643
|
+
"thanks",
|
|
6644
|
+
"bye",
|
|
6645
|
+
"absolutely",
|
|
6646
|
+
"definitely",
|
|
6647
|
+
"exactly",
|
|
6648
|
+
"correct",
|
|
6649
|
+
"true",
|
|
6650
|
+
"false"
|
|
6651
|
+
]);
|
|
6652
|
+
function isTranscriptComplete(text) {
|
|
6653
|
+
const trimmed = text.trim();
|
|
6654
|
+
if (!trimmed) return false;
|
|
6655
|
+
if (/[.?!]$/.test(trimmed)) return true;
|
|
6656
|
+
const words = trimmed.toLowerCase().split(/\s+/);
|
|
6657
|
+
const lastWord = words[words.length - 1];
|
|
6658
|
+
if (COMPLETE_ENDINGS.has(lastWord)) return true;
|
|
6659
|
+
if (INCOMPLETE_ENDINGS.has(lastWord)) return false;
|
|
6660
|
+
const lower = trimmed.toLowerCase();
|
|
6661
|
+
for (const marker of CONTINUATION_MARKERS) if (lower.endsWith(marker)) return false;
|
|
6662
|
+
if (words.length <= 2) return false;
|
|
6663
|
+
return true;
|
|
6664
|
+
}
|
|
6665
|
+
var MAX_HOLD_MS = 2e3;
|
|
6666
|
+
var MIN_MERGE_HOLD_MS = 800;
|
|
6667
|
+
function endpointTier(transcript) {
|
|
6668
|
+
const t = transcript.trim();
|
|
6669
|
+
if (!isTranscriptComplete(t)) return 1;
|
|
6670
|
+
return /[.?!]$/.test(t) ? 3 : 2;
|
|
6671
|
+
}
|
|
6672
|
+
function tierHoldMs(tier, ctx) {
|
|
6673
|
+
if (tier === 1) return ctx.incompleteMs;
|
|
6674
|
+
if (tier === 2) return Math.round((ctx.baseMs + ctx.incompleteMs) / 2);
|
|
6675
|
+
return ctx.baseMs;
|
|
6676
|
+
}
|
|
6677
|
+
function computeHoldMs(ctx) {
|
|
6678
|
+
const t = ctx.transcript.trim();
|
|
6679
|
+
const tier = endpointTier(t);
|
|
6680
|
+
let ms = tierHoldMs(tier, ctx);
|
|
6681
|
+
const ai = ctx.lastAiMessage?.trim();
|
|
6682
|
+
if (ai) {
|
|
6683
|
+
if (/\?$/.test(ai)) ms -= 300;
|
|
6684
|
+
if (ai.split(/\s+/).length > 60) ms += 400;
|
|
6685
|
+
}
|
|
6686
|
+
const lastWord = t.toLowerCase().split(/\s+/).pop();
|
|
6687
|
+
if (lastWord === "um" || lastWord === "uh" || lastWord === "hmm") ms += 500;
|
|
6688
|
+
const floor = tier === 3 ? ctx.baseMs : Math.max(ctx.baseMs, Math.min(tierHoldMs(tier, ctx), MIN_MERGE_HOLD_MS));
|
|
6689
|
+
return Math.max(floor, Math.min(MAX_HOLD_MS, ms));
|
|
6690
|
+
}
|
|
6691
|
+
|
|
6564
6692
|
// node_modules/@bod.ee/voice/src/core/engine.ts
|
|
6565
6693
|
var log13 = forComponent2("VoiceEngine");
|
|
6566
6694
|
var realClock = {
|
|
@@ -6569,7 +6697,7 @@ var realClock = {
|
|
|
6569
6697
|
clearTimeout: (h) => clearTimeout(h)
|
|
6570
6698
|
};
|
|
6571
6699
|
var forSpeech = (t) => t.replace(/[*`#]+/g, "").replace(/(?<![\p{L}\p{N}])_([^_\n]+)_(?![\p{L}\p{N}])/gu, "$1").replace(/^[ \t]*[-•]\s+/gm, "").replace(/\s*[\u2013\u2014]\s*/g, ", ").replace(/[\u2010\u2011]/g, "-").replace(/\s*\|\s*/g, ", ").replace(/(\d)\s+%/g, "$1%").replace(/\.{3,}/g, ".");
|
|
6572
|
-
var normWord = (w) => w.toLowerCase().replace(/[
|
|
6700
|
+
var normWord = (w) => w.toLowerCase().replace(/[^\p{L}\p{M}\p{N}]/gu, "");
|
|
6573
6701
|
var SPEC_TRAILING_OK = /* @__PURE__ */ new Set(["please", "thanks", "thank", "you", "now", "okay", "ok", "kindly", "alright", "then", "though", "right", "yeah"]);
|
|
6574
6702
|
function speculationConfirms(spec, final) {
|
|
6575
6703
|
const sw = spec.trim().split(/\s+/).map(normWord).filter(Boolean);
|
|
@@ -6580,11 +6708,27 @@ function speculationConfirms(spec, final) {
|
|
|
6580
6708
|
for (let i = sw.length; i < fw.length; i++) if (!SPEC_TRAILING_OK.has(fw[i])) return false;
|
|
6581
6709
|
return true;
|
|
6582
6710
|
}
|
|
6711
|
+
function heardPrefix(text, spoken, lookahead = 4) {
|
|
6712
|
+
const toks = [...text.matchAll(/\S+/g)];
|
|
6713
|
+
let i = 0, at = 0;
|
|
6714
|
+
for (const w of spoken) {
|
|
6715
|
+
const nw = normWord(w);
|
|
6716
|
+
if (!nw) continue;
|
|
6717
|
+
for (let k = i; k < Math.min(toks.length, i + lookahead); k++)
|
|
6718
|
+
if (normWord(toks[k][0]) === nw) {
|
|
6719
|
+
at = toks[k].index;
|
|
6720
|
+
i = k + 1;
|
|
6721
|
+
break;
|
|
6722
|
+
}
|
|
6723
|
+
}
|
|
6724
|
+
return at;
|
|
6725
|
+
}
|
|
6583
6726
|
var VoiceEngineOptions = class {
|
|
6584
6727
|
stt;
|
|
6585
6728
|
tts;
|
|
6586
6729
|
player;
|
|
6587
|
-
/** a final utterance arrived (endpoint) — host dispatches it as a turn
|
|
6730
|
+
/** a final utterance arrived (endpoint) — host dispatches it as a turn. `meta.supersedes` marks a fuller
|
|
6731
|
+
* re-finalization of the utterance dispatched just before it: replace that turn, don't add one. */
|
|
6588
6732
|
onUtterance = () => {
|
|
6589
6733
|
};
|
|
6590
6734
|
/** live partial transcript while listening (host renders the 🎤 line) */
|
|
@@ -6592,6 +6736,14 @@ var VoiceEngineOptions = class {
|
|
|
6592
6736
|
};
|
|
6593
6737
|
onState = () => {
|
|
6594
6738
|
};
|
|
6739
|
+
/** The assistant's spoken turn finished AUDIBLY — every TTS chunk arrived AND playback drained.
|
|
6740
|
+
* Fires from the same `settle()` that returns the engine to 'listening', just before that state
|
|
6741
|
+
* change. Distinct from the host's `endSpeech()`, which only closes the TEXT stream (audio is
|
|
6742
|
+
* still playing at that point). NOT fired for a turn cut by barge-in — the host gets `onBargeIn`
|
|
6743
|
+
* instead. Also fires for engine-internal speech (adaptive ack, filler, queued utterances), so a
|
|
6744
|
+
* host that only cares about ITS turns should gate on its own open-turn flag. Default no-op. */
|
|
6745
|
+
onSpeechEnd = () => {
|
|
6746
|
+
};
|
|
6595
6747
|
/** user spoke/acted over playback — host aborts the in-flight turn (called AFTER audio is killed).
|
|
6596
6748
|
* phase: 'speaking' = cut mid-speech (real interruption); 'drain' = in the final audio tail
|
|
6597
6749
|
* (normal turn-taking — hosts shouldn't alarm). */
|
|
@@ -6606,6 +6758,14 @@ var VoiceEngineOptions = class {
|
|
|
6606
6758
|
/** the adaptive ack actually fired (host can mark the turn as spoken — e.g. suppress dead-air repair) */
|
|
6607
6759
|
onAdaptiveAck = () => {
|
|
6608
6760
|
};
|
|
6761
|
+
/** HOST-CHOSEN adaptive ack: asked once per armed ack (at beginSpeech(true), i.e. at dispatch) so a slow pick
|
|
6762
|
+
* runs in parallel with the reflex. Return the phrase to speak, or `null` = stay silent this turn. A Promise
|
|
6763
|
+
* still pending when the ack is due gets up to another `adaptiveAckMs` (then the turn stays silent — a wrong
|
|
6764
|
+
* ack is worse than none); a rejection falls back to the default pools. Unset (null) = the engine's own
|
|
6765
|
+
* shape-picked pools (ACKS_NEUTRAL / ACKS_QUESTION). Phrases enter the echo set like the defaults — keep them
|
|
6766
|
+
* short and not a lone word users say on their own ("okay", "yeah"): the ack-squash guard drops a user
|
|
6767
|
+
* utterance that equals the last ack. */
|
|
6768
|
+
pickAck = null;
|
|
6609
6769
|
/** Endpoint merge window (ms): hold an endpointed utterance briefly — if speech resumes (spelled
|
|
6610
6770
|
* letters, mid-thought pauses), the next utterance MERGES instead of dispatching a truncated one
|
|
6611
6771
|
* ("E-L-Y." / "A."). Costs this much latency per turn; 0 disables. */
|
|
@@ -6613,6 +6773,21 @@ var VoiceEngineOptions = class {
|
|
|
6613
6773
|
/** Extended merge window (ms) for utterances that look incomplete (trailing conjunction/filler).
|
|
6614
6774
|
* Gives the user time to finish their thought without triggering a model call. */
|
|
6615
6775
|
incompleteMergeMs = 1500;
|
|
6776
|
+
/** ADAPTIVE endpoint hold (opt-in; default OFF = today's byte-identical two-tier behavior).
|
|
6777
|
+
* The fixed merge tiers (`utteranceMergeMs` vs `incompleteMergeMs`, chosen by a trailing-word
|
|
6778
|
+
* regex, and skipped entirely at ≥4 words) dispatch mid-thought FRAGMENTS as their own turn: live,
|
|
6779
|
+
* "…give me the best" dispatched, got an empty reply, and "…give me the best of it." arrived 766ms
|
|
6780
|
+
* later as a second turn. When true, every endpointed utterance is held for
|
|
6781
|
+
* `computeHoldMs(...)` — sized by linguistic completeness, whether the STT emitted a sentence-final
|
|
6782
|
+
* mark, and what the assistant last said — so the continuation MERGES instead of burning a turn.
|
|
6783
|
+
* COST — this is strictly slower than the default path, never faster: today every ≥4-word utterance
|
|
6784
|
+
* dispatches at 0ms (the fast path below), so adaptive ADDS the whole hold — ~350ms to every
|
|
6785
|
+
* sentence-final utterance, ~925ms to every complete-looking unpunctuated one (the common case),
|
|
6786
|
+
* and up to 2s on a mid-thought fragment. Paid on turns that didn't need it, to stop burning a turn
|
|
6787
|
+
* (empty reply + blank bubble) on the ones that did — hence opt-in. Also changes `onHold` timing:
|
|
6788
|
+
* it now fires on every tier-2/3 hold, not only on `looksIncomplete` (`holdFiller` stays tier-1
|
|
6789
|
+
* only). See core/endpointing.ts. */
|
|
6790
|
+
adaptiveEndpointing = false;
|
|
6616
6791
|
/** Grace window (ms) after an utterance dispatches, during which the user's own trailing audio cannot
|
|
6617
6792
|
* barge the reply it requested. Soniox keeps finalizing partials past <end>; without this they read
|
|
6618
6793
|
* as a barge and abort the fresh turn (live: mid-sentence self-interruption + steps=1→steps=0 double
|
|
@@ -6623,6 +6798,15 @@ var VoiceEngineOptions = class {
|
|
|
6623
6798
|
* while audible (speaking + drain tail), so echo can never become a phantom turn — the right mode
|
|
6624
6799
|
* when there's no AEC (e.g. the non-VPIO mic fallback) and no headphones. Cost: can't interrupt. */
|
|
6625
6800
|
bargeIn = true;
|
|
6801
|
+
/** Which barge-in TIER to run — the host's override of the mic's self-report.
|
|
6802
|
+
* - `'auto'` (default) — trust `stt.usingAec`: AEC tier when the mic reports echo cancellation
|
|
6803
|
+
* applied, heuristic/energy tier otherwise. Unchanged legacy behavior.
|
|
6804
|
+
* - `'aec'` — force the AEC tier (STT stays live through TTS; barge decided by the dominant-novel
|
|
6805
|
+
* gate + overlap pause) even if the mic reports no echo cancellation.
|
|
6806
|
+
* - `'energy'` — force the heuristic tier (echo-baseline EMA spike arms `suspectUntil`, novel-word
|
|
6807
|
+
* count decides; STT buffer reset after our own audio) even if the mic REPORTS echoCancellation.
|
|
6808
|
+
* Mobile Safari reports `true` while cancelling poorly — that report is what this overrides. */
|
|
6809
|
+
bargeInMode = "auto";
|
|
6626
6810
|
/** Filler phrase spoken when holding for an incomplete utterance ('' disables). */
|
|
6627
6811
|
holdFiller = "";
|
|
6628
6812
|
/** Called when the engine holds an incomplete utterance (host can render a visual cue). */
|
|
@@ -6646,6 +6830,15 @@ var VoiceEngineOptions = class {
|
|
|
6646
6830
|
* Mechanism-based discriminator: a re-PAUSE this soon after a resume = a persistent human, not an
|
|
6647
6831
|
* echo blip (which pauses once and stalls). Cede on the re-pause regardless of the novel gate. */
|
|
6648
6832
|
overlapRepauseCedeMs = 1500;
|
|
6833
|
+
/** HOST-DECIDED short overlaps (opt-in; 0 = off = cede on ≥2 genuine words, as always). The fast "≥2 novel words →
|
|
6834
|
+
* cede" rule cannot tell a reaction ("Oh, that's new") from a barge, and it fires ~300ms before a host-side judge
|
|
6835
|
+
* (e.g. an AI instinct) can answer — so a judge could never keep a delivery going (live 20260923-125143: ceded on
|
|
6836
|
+
* "Oh, that", the judge said keep_talking 300ms later). When > 0, a SHORT genuine overlap (≤ overlapDecideMaxWords,
|
|
6837
|
+
* no '?', no stop/question word) keeps the pause and waits for `decideOverlap()`; with no decision after this many
|
|
6838
|
+
* ms it cedes. Long or obvious barges still cede instantly. Audio is already paused, so waiting costs no talk-over. */
|
|
6839
|
+
overlapDecideMs = 0;
|
|
6840
|
+
/** Longest overlap (words) the host decides; also how far an overlap the host kept may grow before it cedes. */
|
|
6841
|
+
overlapDecideMaxWords = 4;
|
|
6649
6842
|
/** Speculative ENERGY pre-pause while speaking (AEC tier): two residue gate-passes within 350ms →
|
|
6650
6843
|
* pause ~300ms before the STT tokens land. But energy CANNOT separate residue bursts from speech
|
|
6651
6844
|
* (the documented whack-a-mole) — so a residue spike during loud playback false-pauses with NO user
|
|
@@ -6698,6 +6891,10 @@ var VoiceEngineOptions = class {
|
|
|
6698
6891
|
* timestamp-emitting TTS (CartesiaTTS.wantTimestamps) driven over `AudioSink.playedMs`.
|
|
6699
6892
|
*/
|
|
6700
6893
|
revealMode = "off";
|
|
6894
|
+
/** Locate EXACTLY what the user heard when a barge cuts speech: request word timestamps from the TTS (when it
|
|
6895
|
+
* supports them) and match them against the playback clock (InterruptedReply `heard`/`exact`). Independent of
|
|
6896
|
+
* `revealMode` — no reveal events. Without timestamps the cut falls back to the ~15 chars/s estimate. */
|
|
6897
|
+
heardTimestamps = true;
|
|
6701
6898
|
/** Called with the cumulative revealed text for the current assistant turn (see `revealMode`).
|
|
6702
6899
|
* Reset to '' at the start of each spoken turn. */
|
|
6703
6900
|
onReveal = () => {
|
|
@@ -6728,8 +6925,27 @@ var VoiceEngine = class _VoiceEngine {
|
|
|
6728
6925
|
// cumulative revealed (on-screen) text for the current turn — see revealMode
|
|
6729
6926
|
// 'word' karaoke reveal: spoken-word start times (sec, absolute from turn-audio start) + poll state.
|
|
6730
6927
|
wordStarts = [];
|
|
6928
|
+
wordTexts = [];
|
|
6929
|
+
// the timestamped words, parallel to wordStarts (heard-cut matching)
|
|
6930
|
+
ackWords = 0;
|
|
6931
|
+
// leading timestamped words that are the ack spoken on this turn's context, not the reply
|
|
6731
6932
|
revealedN = 0;
|
|
6732
6933
|
revealPoll = null;
|
|
6934
|
+
// Sub-turn timestamp anchoring. A TTS context emits word starts relative to ITS OWN audio start, so
|
|
6935
|
+
// `wordStarts` (one flat turn timeline) is only valid while the turn stays on one context. It does
|
|
6936
|
+
// not: `subturnBoundary()` rotates the context, and Cartesia ALSO rotates it on its own (~5s idle
|
|
6937
|
+
// timeout mid tool-call) — after which starts restart at 0 and every remaining word instantly
|
|
6938
|
+
// satisfies the reveal predicate, dumping the whole segment on screen.
|
|
6939
|
+
// Fix is deterministic, not detective: at a boundary we FORCE the rotation ourselves, so the new
|
|
6940
|
+
// context's origin is always known — it is the turn-audio position where its first byte lands,
|
|
6941
|
+
// captured from the sink at that byte (`playedMs + drainMs` = content seconds written so far).
|
|
6942
|
+
// Starts arriving before that byte are held in `wordPending` and rebased when it does.
|
|
6943
|
+
wordOrigin = 0;
|
|
6944
|
+
// sec offset added to incoming starts (0 = first sub-turn)
|
|
6945
|
+
wordAnchorPending = false;
|
|
6946
|
+
// awaiting the new sub-turn's first audio byte
|
|
6947
|
+
wordPending = [];
|
|
6948
|
+
// [word, start] received while pending, rebased on capture
|
|
6733
6949
|
clock;
|
|
6734
6950
|
drainTimer = null;
|
|
6735
6951
|
// heuristic tier state (inert under AEC) — frozen as validated in the experiment
|
|
@@ -6760,6 +6976,8 @@ var VoiceEngine = class _VoiceEngine {
|
|
|
6760
6976
|
// endpointed text held for the merge window
|
|
6761
6977
|
mergePath = "direct";
|
|
6762
6978
|
// how the pending utterance was assembled (diag)
|
|
6979
|
+
holdTier = 0;
|
|
6980
|
+
// adaptive tier of the hold this dispatch came out of (diag; 0 = none)
|
|
6763
6981
|
lastGraceDiag = 0;
|
|
6764
6982
|
// grace_suppress emitted once per grace window
|
|
6765
6983
|
pendingTimer = null;
|
|
@@ -6770,10 +6988,24 @@ var VoiceEngine = class _VoiceEngine {
|
|
|
6770
6988
|
// the agent replied (or after 3s) still dispatches.
|
|
6771
6989
|
lastDispatchFlat = "";
|
|
6772
6990
|
lastDispatchWords = [];
|
|
6991
|
+
lastDispatchText = "";
|
|
6773
6992
|
lastDispatchAt = 0;
|
|
6774
6993
|
repliedSinceDispatch = false;
|
|
6994
|
+
dispatchReply = "";
|
|
6995
|
+
// the agent's prose since the last dispatch (answeredSinceDispatch)
|
|
6775
6996
|
static DUP_FINAL_MS = 3e3;
|
|
6776
6997
|
lastInterrupted = null;
|
|
6998
|
+
queuedTurn = false;
|
|
6999
|
+
// the current spoken turn is a queued worker utterance (pumpQueue), not a host turn
|
|
7000
|
+
// host-decided overlap (options.overlapDecideMs): pending decision timer, a final that landed while waiting, and
|
|
7001
|
+
// the window in which the overlap the host chose to talk over is absorbed (its growth + its final)
|
|
7002
|
+
decideTimer = null;
|
|
7003
|
+
decideSince = 0;
|
|
7004
|
+
// when this hold began waiting for the host — caps re-decisions (3× overlapDecideMs)
|
|
7005
|
+
decideHeldFinal = "";
|
|
7006
|
+
overlapKeptUntil = 0;
|
|
7007
|
+
overlapKept = "";
|
|
7008
|
+
// what the host judged keep_talking on — only THOSE words are absorbed
|
|
6777
7009
|
// overlap (pause) tier state — AEC + pause-capable sinks only
|
|
6778
7010
|
pausedAt = 0;
|
|
6779
7011
|
lastResumeAt = 0;
|
|
@@ -6822,13 +7054,21 @@ var VoiceEngine = class _VoiceEngine {
|
|
|
6822
7054
|
}
|
|
6823
7055
|
async start() {
|
|
6824
7056
|
this.tts.onAudio = (c) => {
|
|
6825
|
-
if (this.speaking
|
|
7057
|
+
if (!this.speaking && !this.bcActive) return;
|
|
7058
|
+
if (this.wordAnchorPending && this.speaking) this.anchorSubturn();
|
|
7059
|
+
this.player.write(c);
|
|
6826
7060
|
};
|
|
6827
|
-
if (this.options.revealMode === "word") {
|
|
7061
|
+
if (this.options.revealMode === "word" || this.options.heardTimestamps) {
|
|
6828
7062
|
this.tts.wantTimestamps = true;
|
|
6829
|
-
this.tts.onTimestamps = (
|
|
7063
|
+
this.tts.onTimestamps = (words, start) => {
|
|
6830
7064
|
if (this.interrupted) return;
|
|
6831
|
-
|
|
7065
|
+
start.forEach((s, i) => {
|
|
7066
|
+
if (this.wordAnchorPending) this.wordPending.push([words[i] ?? "", s]);
|
|
7067
|
+
else {
|
|
7068
|
+
this.wordStarts.push(this.wordOrigin + s);
|
|
7069
|
+
this.wordTexts.push(words[i] ?? "");
|
|
7070
|
+
}
|
|
7071
|
+
});
|
|
6832
7072
|
};
|
|
6833
7073
|
}
|
|
6834
7074
|
this.stt.onPartial = (text) => this.handlePartial(text);
|
|
@@ -6837,10 +7077,15 @@ var VoiceEngine = class _VoiceEngine {
|
|
|
6837
7077
|
await Promise.all([this.tts.connect(), this.stt.start()]);
|
|
6838
7078
|
this.tts.warmup?.();
|
|
6839
7079
|
this.setState("listening");
|
|
6840
|
-
|
|
7080
|
+
if (this.options.bargeInMode === "aec" && !this.stt.usingAec)
|
|
7081
|
+
log13.warn("bargeInMode=aec but mic does not report echoCancellation applied \u2014 STT may transcribe the assistant");
|
|
7082
|
+
log13.debug(`voice I/O up (${this.usingAec ? "AEC" : "heuristic echo"} capture, mode=${this.options.bargeInMode}, mic reports aec=${this.stt.usingAec})`);
|
|
6841
7083
|
}
|
|
7084
|
+
/** The RESOLVED barge-in tier (the single seam every tier branch reads). `bargeInMode` overrides
|
|
7085
|
+
* the mic's self-report — see the option doc for why the report is not trustworthy on mobile. */
|
|
6842
7086
|
get usingAec() {
|
|
6843
|
-
|
|
7087
|
+
const m = this.options.bargeInMode;
|
|
7088
|
+
return m === "aec" ? true : m === "energy" ? false : this.stt.usingAec;
|
|
6844
7089
|
}
|
|
6845
7090
|
/** Flip barge-in at runtime (e.g. the mic fell back to non-VPIO → go half-duplex so echo can't leak). */
|
|
6846
7091
|
setBargeIn(on) {
|
|
@@ -6872,6 +7117,12 @@ var VoiceEngine = class _VoiceEngine {
|
|
|
6872
7117
|
for (const r of this.idleWaiters.splice(0)) r();
|
|
6873
7118
|
}
|
|
6874
7119
|
}
|
|
7120
|
+
/** True while the assistant is AUDIBLE — deltas flowing or queued audio still draining. Goes false
|
|
7121
|
+
* exactly when `onSpeechEnd` fires (or immediately on barge-in). Lets a host tell "the turn closed
|
|
7122
|
+
* but audio is still playing" from "there was nothing to play", without waiting for a callback. */
|
|
7123
|
+
get audible() {
|
|
7124
|
+
return this.speaking;
|
|
7125
|
+
}
|
|
6875
7126
|
/** Resolve when the engine is no longer speaking (immediate if already idle). */
|
|
6876
7127
|
awaitIdle() {
|
|
6877
7128
|
if (this.state !== "speaking" && this.state !== "thinking") return Promise.resolve();
|
|
@@ -6888,19 +7139,31 @@ var VoiceEngine = class _VoiceEngine {
|
|
|
6888
7139
|
this.drainTimer = null;
|
|
6889
7140
|
}
|
|
6890
7141
|
this.interrupted = false;
|
|
7142
|
+
this.queuedTurn = false;
|
|
6891
7143
|
this.bcSupersede();
|
|
6892
7144
|
this.resetOverlap(true);
|
|
7145
|
+
const acked = ack && this.dispatchRefinal && this.ackSpent;
|
|
7146
|
+
if (acked) {
|
|
7147
|
+
ack = false;
|
|
7148
|
+
this.diag("ack_skipped", { reason: "refinal" });
|
|
7149
|
+
}
|
|
6893
7150
|
this.diag("tts_turn_begin", { ack, gapless: this.speaking });
|
|
7151
|
+
const gapless = this.speaking;
|
|
6894
7152
|
if (!this.speaking) this.player.markTurn();
|
|
6895
7153
|
this.speaking = true;
|
|
6896
7154
|
this.ctxOpen = true;
|
|
6897
7155
|
this.spokeDeltas = false;
|
|
6898
|
-
this.adaptiveAckFired =
|
|
6899
|
-
this.ackProbe = null;
|
|
7156
|
+
this.adaptiveAckFired = acked;
|
|
7157
|
+
this.ackProbe = acked ? "" : null;
|
|
7158
|
+
this.ackPickedFired = false;
|
|
6900
7159
|
this.reply = "";
|
|
6901
7160
|
this.revealText = "";
|
|
6902
7161
|
this.wordStarts = [];
|
|
7162
|
+
this.wordTexts = [];
|
|
7163
|
+
this.ackWords = 0;
|
|
6903
7164
|
this.revealedN = 0;
|
|
7165
|
+
this.resetSubturnAnchor();
|
|
7166
|
+
if (gapless) this.wordAnchorPending = true;
|
|
6904
7167
|
if (this.options.revealMode === "word") this.startWordReveal();
|
|
6905
7168
|
this.emo = this.options.emotions ? new EmotionStream(this.options.showEmotions) : null;
|
|
6906
7169
|
this.echoWords = new Set(this.words(this.prevReply));
|
|
@@ -6912,23 +7175,27 @@ var VoiceEngine = class _VoiceEngine {
|
|
|
6912
7175
|
this.spokeDeltas = true;
|
|
6913
7176
|
this.ackAt = this.clock.now();
|
|
6914
7177
|
this.lastAck = this.options.ackPhrase;
|
|
7178
|
+
this.ackSpent = true;
|
|
7179
|
+
this.ackWords = this.options.ackPhrase.trim().split(/\s+/).length;
|
|
6915
7180
|
for (const w of this.words(this.options.ackPhrase)) this.echoWords.add(w);
|
|
6916
7181
|
} else if (ack && this.options.adaptiveAckMs > 0) {
|
|
7182
|
+
const pick = this.startAckPick();
|
|
7183
|
+
const ms = this.options.adaptiveAckMs;
|
|
6917
7184
|
this.ackTimer = this.clock.setTimeout(() => {
|
|
6918
7185
|
this.ackTimer = null;
|
|
6919
|
-
if (!
|
|
6920
|
-
|
|
6921
|
-
|
|
6922
|
-
|
|
6923
|
-
|
|
6924
|
-
|
|
6925
|
-
|
|
6926
|
-
|
|
6927
|
-
|
|
6928
|
-
|
|
6929
|
-
|
|
6930
|
-
|
|
6931
|
-
|
|
7186
|
+
if (!pick.pending) return this.fireAck(pick.phrase, pick.picked);
|
|
7187
|
+
this.ackTimer = this.clock.setTimeout(() => {
|
|
7188
|
+
this.ackTimer = null;
|
|
7189
|
+
pick.onDone = null;
|
|
7190
|
+
this.ackPick = null;
|
|
7191
|
+
this.diag("ack_cancelled", { reason: "pick_late" });
|
|
7192
|
+
}, ms);
|
|
7193
|
+
pick.onDone = () => {
|
|
7194
|
+
this.clearAckTimer();
|
|
7195
|
+
this.fireAck(pick.phrase, pick.picked);
|
|
7196
|
+
};
|
|
7197
|
+
}, ms);
|
|
7198
|
+
this.ackPick = pick;
|
|
6932
7199
|
}
|
|
6933
7200
|
if (!this.turnStartAt) this.turnStartAt = this.clock.now();
|
|
6934
7201
|
this.setState("thinking");
|
|
@@ -6948,11 +7215,16 @@ var VoiceEngine = class _VoiceEngine {
|
|
|
6948
7215
|
this.adaptiveAckFired = false;
|
|
6949
7216
|
const lead = m ? m[1] : bare;
|
|
6950
7217
|
const norm2 = lead.replace(/[^a-z ]/gi, " ").replace(/\s+/g, " ").trim().toLowerCase();
|
|
7218
|
+
const same = this.ackPickedFired ? bare.match(_VoiceEngine.leadingPhrase(this.lastAck)) : null;
|
|
6951
7219
|
if (_VoiceEngine.ACK_LEADING.test(norm2)) {
|
|
6952
7220
|
const rest = m ? m[2] : "";
|
|
6953
7221
|
this.diag("reflex_ack_suppressed", { lead: lead.trim() });
|
|
6954
7222
|
if (!rest.trim()) return "";
|
|
6955
7223
|
text = rest;
|
|
7224
|
+
} else if (same) {
|
|
7225
|
+
this.diag("reflex_ack_suppressed", { lead: same[0].trim() });
|
|
7226
|
+
text = bare.slice(same[0].length);
|
|
7227
|
+
if (!text.trim()) return "";
|
|
6956
7228
|
} else {
|
|
6957
7229
|
text = probe;
|
|
6958
7230
|
}
|
|
@@ -6965,7 +7237,10 @@ var VoiceEngine = class _VoiceEngine {
|
|
|
6965
7237
|
prose = " " + prose;
|
|
6966
7238
|
}
|
|
6967
7239
|
this.reply += prose;
|
|
6968
|
-
if (prose.trim())
|
|
7240
|
+
if (prose.trim()) {
|
|
7241
|
+
this.repliedSinceDispatch = true;
|
|
7242
|
+
this.dispatchReply += prose;
|
|
7243
|
+
}
|
|
6969
7244
|
for (const w of this.words(this.reply)) this.echoWords.add(w);
|
|
6970
7245
|
this.tts.speak(forSpeech(speech), true);
|
|
6971
7246
|
if (!this.spokeDeltas && this.turnStartAt) log13.debug(`ttft: ${Math.round(this.clock.now() - this.turnStartAt)}ms`);
|
|
@@ -6977,6 +7252,42 @@ var VoiceEngine = class _VoiceEngine {
|
|
|
6977
7252
|
}
|
|
6978
7253
|
return display;
|
|
6979
7254
|
}
|
|
7255
|
+
/** Sub-turn seam: the host's agent stream interrupted the reply with a tool call, so the spoken
|
|
7256
|
+
* turn continues on a FRESH TTS context. Flushes the current context and opens the next, then
|
|
7257
|
+
* re-anchors the karaoke timeline at the position the next context's first audio byte lands on.
|
|
7258
|
+
*
|
|
7259
|
+
* Rotating unconditionally is the point: Cartesia restarts word timestamps at 0 for each context
|
|
7260
|
+
* and ALSO rotates on its own after ~5s idle — so leaving the context alone makes reveal timing
|
|
7261
|
+
* depend on an invisible server-side timer (short gap = one timeline, long gap = a restart we
|
|
7262
|
+
* never saw). By always rotating, the origin is always known and never inferred.
|
|
7263
|
+
*
|
|
7264
|
+
* No-op outside an open turn. Reveal text/word ordering is unchanged — only the time origin. */
|
|
7265
|
+
subturnBoundary() {
|
|
7266
|
+
if (!this.speaking || !this.ctxOpen) return;
|
|
7267
|
+
this.diag("tts_subturn_boundary", { playedMs: Math.round(Math.max(0, this.player.playedMs())) });
|
|
7268
|
+
this.tts.end();
|
|
7269
|
+
this.tts.newContext();
|
|
7270
|
+
this.wordAnchorPending = true;
|
|
7271
|
+
this.wordPending = [];
|
|
7272
|
+
}
|
|
7273
|
+
/** Capture the current sub-turn's time origin = content seconds already written to the sink.
|
|
7274
|
+
* `playedMs + drainMs` is that sum in both regimes (mid-playback it telescopes to the scheduled
|
|
7275
|
+
* content; fully drained, drainMs is 0 and playedMs has reached the scheduled total). */
|
|
7276
|
+
anchorSubturn() {
|
|
7277
|
+
this.wordAnchorPending = false;
|
|
7278
|
+
this.wordOrigin = Math.max(0, this.player.playedMs() + this.player.drainMs()) / 1e3;
|
|
7279
|
+
this.diag("reveal_anchor", { originSec: Number(this.wordOrigin.toFixed(3)), held: this.wordPending.length });
|
|
7280
|
+
for (const [w, s] of this.wordPending) {
|
|
7281
|
+
this.wordStarts.push(this.wordOrigin + s);
|
|
7282
|
+
this.wordTexts.push(w);
|
|
7283
|
+
}
|
|
7284
|
+
this.wordPending = [];
|
|
7285
|
+
}
|
|
7286
|
+
resetSubturnAnchor() {
|
|
7287
|
+
this.wordOrigin = 0;
|
|
7288
|
+
this.wordAnchorPending = false;
|
|
7289
|
+
this.wordPending = [];
|
|
7290
|
+
}
|
|
6980
7291
|
/** Karaoke reveal poll: reveal the display-word prefix up to the count of spoken words whose
|
|
6981
7292
|
* Cartesia start time has been reached on the playback clock (`AudioSink.playedMs`, which excludes
|
|
6982
7293
|
* paused time). Poll-driven (not AudioContext timers) so it's portable across CLI/browser sinks and
|
|
@@ -7048,6 +7359,11 @@ var VoiceEngine = class _VoiceEngine {
|
|
|
7048
7359
|
if (this.turnStartAt) log13.debug(`turn: ${Math.round(this.clock.now() - this.turnStartAt)}ms (incl. playback)`);
|
|
7049
7360
|
this.echoUntil = this.clock.now() + 2500;
|
|
7050
7361
|
if (!this.usingAec) this.stt.reset();
|
|
7362
|
+
try {
|
|
7363
|
+
this.options.onSpeechEnd();
|
|
7364
|
+
} catch (e) {
|
|
7365
|
+
log13.warn(`onSpeechEnd threw: ${e instanceof Error ? e.message : e}`);
|
|
7366
|
+
}
|
|
7051
7367
|
this.setState("listening");
|
|
7052
7368
|
if (this.uttQueue.length) this.pumpQueue();
|
|
7053
7369
|
};
|
|
@@ -7062,8 +7378,9 @@ var VoiceEngine = class _VoiceEngine {
|
|
|
7062
7378
|
this.drainTimer = this.clock.setTimeout(drainThenSettle, 15e3);
|
|
7063
7379
|
} else drainThenSettle();
|
|
7064
7380
|
}
|
|
7065
|
-
/**
|
|
7066
|
-
* the user did NOT hear
|
|
7381
|
+
/** What the last barge-in cut — the reply that was playing (and its estimated heard part) plus every queued worker
|
|
7382
|
+
* utterance it dropped — consumed by the host to tell the model what the user did NOT hear, or to park the rest of
|
|
7383
|
+
* a delivery for resuming. Cleared on read. */
|
|
7067
7384
|
takeInterruptedReply() {
|
|
7068
7385
|
const r = this.lastInterrupted;
|
|
7069
7386
|
this.lastInterrupted = null;
|
|
@@ -7072,11 +7389,12 @@ var VoiceEngine = class _VoiceEngine {
|
|
|
7072
7389
|
/** Speak a short filler phrase without starting a model turn (stays in listening mode after). */
|
|
7073
7390
|
speakFiller(text) {
|
|
7074
7391
|
if (!text || this.speaking) return;
|
|
7075
|
-
const replied = this.repliedSinceDispatch;
|
|
7392
|
+
const replied = this.repliedSinceDispatch, reply = this.dispatchReply;
|
|
7076
7393
|
this.beginSpeech();
|
|
7077
7394
|
this.speakDelta(text);
|
|
7078
7395
|
this.endSpeech();
|
|
7079
7396
|
this.repliedSinceDispatch = replied;
|
|
7397
|
+
this.dispatchReply = reply;
|
|
7080
7398
|
}
|
|
7081
7399
|
/** Enqueue a COMPLETE worker utterance (already-split spoken text) onto the central speech queue.
|
|
7082
7400
|
* If nothing is currently speaking it plays immediately; otherwise it queues and plays after the
|
|
@@ -7094,6 +7412,7 @@ var VoiceEngine = class _VoiceEngine {
|
|
|
7094
7412
|
const text = this.uttQueue.shift();
|
|
7095
7413
|
if (text == null) return;
|
|
7096
7414
|
this.beginSpeech();
|
|
7415
|
+
this.queuedTurn = true;
|
|
7097
7416
|
this.speakDelta(text);
|
|
7098
7417
|
this.endSpeech();
|
|
7099
7418
|
}
|
|
@@ -7108,6 +7427,69 @@ var VoiceEngine = class _VoiceEngine {
|
|
|
7108
7427
|
* micro-ack already spoke one. Anchored end-to-end so only a whole ack-only clause matches. */
|
|
7109
7428
|
static ACK_LEADING = /^(?:(?:okay|ok|alright|sure|yeah|yep|right|got it|on it|still|still on it|still working on it|working on it|checking|one sec|one moment|mm hm|mmhm|hmm|let me (?:see|check|think))[ ,]*)+$/;
|
|
7110
7429
|
recentAcks = [];
|
|
7430
|
+
/** The armed turn's ack choice: the host's `pickAck` (sync or async); `phrase` undefined = the default pools, drawn
|
|
7431
|
+
* only when the ack actually fires. `onDone` is set only while the ack is due and the pick is still pending. */
|
|
7432
|
+
ackPick = null;
|
|
7433
|
+
startAckPick() {
|
|
7434
|
+
const host = this.options.pickAck;
|
|
7435
|
+
const pick = { pending: false, phrase: void 0, picked: !!host, onDone: null };
|
|
7436
|
+
if (!host) return pick;
|
|
7437
|
+
const ctx = { text: this.lastDispatchText, question: this.lastDispatchWasQuestion, barged: this.bargedBeforeDispatch };
|
|
7438
|
+
const fallback = (e) => {
|
|
7439
|
+
log13.warn(`pickAck failed \u2014 using the default pools: ${e instanceof Error ? e.message : e}`);
|
|
7440
|
+
pick.picked = false;
|
|
7441
|
+
return void 0;
|
|
7442
|
+
};
|
|
7443
|
+
let r;
|
|
7444
|
+
try {
|
|
7445
|
+
r = host(ctx);
|
|
7446
|
+
} catch (e) {
|
|
7447
|
+
pick.phrase = fallback(e);
|
|
7448
|
+
return pick;
|
|
7449
|
+
}
|
|
7450
|
+
if (typeof r?.then !== "function") {
|
|
7451
|
+
pick.phrase = r;
|
|
7452
|
+
return pick;
|
|
7453
|
+
}
|
|
7454
|
+
pick.pending = true;
|
|
7455
|
+
Promise.resolve(r).catch(fallback).then((phrase) => {
|
|
7456
|
+
pick.pending = false;
|
|
7457
|
+
pick.phrase = phrase;
|
|
7458
|
+
if (this.ackPick === pick) pick.onDone?.();
|
|
7459
|
+
});
|
|
7460
|
+
return pick;
|
|
7461
|
+
}
|
|
7462
|
+
/** Speak the adaptive ack (null = the host chose silence, undefined = draw from the default pools) — only if the
|
|
7463
|
+
* turn is still waiting on its reflex. */
|
|
7464
|
+
fireAck(phrase, picked) {
|
|
7465
|
+
this.ackPick = null;
|
|
7466
|
+
if (!this.speaking || !this.ctxOpen || this.spokeDeltas || this.interrupted) return;
|
|
7467
|
+
if (phrase === void 0) phrase = this.pickAck();
|
|
7468
|
+
if (phrase === null || !phrase.trim()) {
|
|
7469
|
+
this.diag("ack_skipped", { reason: "silent" });
|
|
7470
|
+
return;
|
|
7471
|
+
}
|
|
7472
|
+
this.diag("ack_fired", { phrase, adaptive: true, waitedMs: this.options.adaptiveAckMs, ...picked ? { picked } : {} });
|
|
7473
|
+
this.tts.speak(phrase + " ", true);
|
|
7474
|
+
this.spokeDeltas = true;
|
|
7475
|
+
this.ackAt = this.clock.now();
|
|
7476
|
+
this.lastAck = phrase;
|
|
7477
|
+
this.ackSpent = true;
|
|
7478
|
+
this.ackWords = phrase.trim().split(/\s+/).length;
|
|
7479
|
+
for (const w of this.words(phrase)) this.echoWords.add(w);
|
|
7480
|
+
this.setState("speaking");
|
|
7481
|
+
this.adaptiveAckFired = true;
|
|
7482
|
+
this.ackProbe = "";
|
|
7483
|
+
this.ackPickedFired = picked;
|
|
7484
|
+
this.options.onAdaptiveAck();
|
|
7485
|
+
}
|
|
7486
|
+
/** `phrase`'s words at the very start of a text, any punctuation between them ("Oh no." ~ "oh, no —"). */
|
|
7487
|
+
static leadingPhrase(phrase) {
|
|
7488
|
+
const w = phrase.toLowerCase().match(/[a-z0-9]+/g) ?? [];
|
|
7489
|
+
return w.length ? new RegExp(`^[^a-z0-9]*${w.join("[^a-z0-9]+")}(?![a-z0-9])[^a-z0-9]*`, "i") : /$^/;
|
|
7490
|
+
}
|
|
7491
|
+
ackPickedFired = false;
|
|
7492
|
+
// the fired ack was the host's pick → the probe also strips the reflex repeating it
|
|
7111
7493
|
pickAck() {
|
|
7112
7494
|
const pool = this.lastDispatchWasQuestion ? _VoiceEngine.ACKS_QUESTION : _VoiceEngine.ACKS_NEUTRAL;
|
|
7113
7495
|
const fresh = pool.filter((p) => !this.recentAcks.includes(p));
|
|
@@ -7117,7 +7499,16 @@ var VoiceEngine = class _VoiceEngine {
|
|
|
7117
7499
|
return phrase;
|
|
7118
7500
|
}
|
|
7119
7501
|
lastDispatchWasQuestion = false;
|
|
7502
|
+
dispatchRefinal = false;
|
|
7503
|
+
// the last dispatch re-finalized (superseded) the one before it — same logical turn
|
|
7504
|
+
ackSpent = false;
|
|
7505
|
+
// an ack already played for the current logical turn (reset by a non-refinal dispatch)
|
|
7506
|
+
bargedSinceDispatch = false;
|
|
7507
|
+
// an effective interrupt landed since the last dispatch
|
|
7508
|
+
bargedBeforeDispatch = false;
|
|
7509
|
+
// …as of the last dispatch (AckContext.barged)
|
|
7120
7510
|
clearAckTimer(reason) {
|
|
7511
|
+
if (reason) this.ackPick = null;
|
|
7121
7512
|
if (!this.ackTimer) return;
|
|
7122
7513
|
this.clock.clearTimeout(this.ackTimer);
|
|
7123
7514
|
this.ackTimer = null;
|
|
@@ -7133,11 +7524,16 @@ var VoiceEngine = class _VoiceEngine {
|
|
|
7133
7524
|
this.clearAckTimer("interrupt");
|
|
7134
7525
|
this.adaptiveAckFired = false;
|
|
7135
7526
|
this.ackProbe = null;
|
|
7136
|
-
|
|
7137
|
-
const droppedQueued =
|
|
7138
|
-
|
|
7139
|
-
|
|
7527
|
+
const queued = this.uttQueue.splice(0);
|
|
7528
|
+
const droppedQueued = queued.length;
|
|
7529
|
+
if (droppedQueued) log13.info(`barge-in cut ${droppedQueued} queued worker utterance(s) \u2014 handed to the host`);
|
|
7530
|
+
this.overlapKeptUntil = 0;
|
|
7531
|
+
if (!this.speaking && !this.drainTimer) {
|
|
7532
|
+
if (droppedQueued) this.lastInterrupted = { full: "", heard: "", queued, queuedTurn: false };
|
|
7533
|
+
return;
|
|
7534
|
+
}
|
|
7140
7535
|
this.stopWordReveal(false);
|
|
7536
|
+
this.resetSubturnAnchor();
|
|
7141
7537
|
this.diag("interrupt", { droppedQueued, ctxOpen: this.ctxOpen, playedMs: Math.round(Math.max(0, this.player.playedMs())) });
|
|
7142
7538
|
if (this.drainTimer) {
|
|
7143
7539
|
this.clock.clearTimeout(this.drainTimer);
|
|
@@ -7145,8 +7541,9 @@ var VoiceEngine = class _VoiceEngine {
|
|
|
7145
7541
|
}
|
|
7146
7542
|
this.resetOverlap(false);
|
|
7147
7543
|
this.lastResumeAt = 0;
|
|
7148
|
-
const
|
|
7149
|
-
if (this.reply) this.lastInterrupted = { full: this.reply, heard: this.
|
|
7544
|
+
const { heard, exact } = this.heardCut();
|
|
7545
|
+
if (this.reply || droppedQueued) this.lastInterrupted = { full: this.reply, heard, exact, queued, queuedTurn: this.queuedTurn };
|
|
7546
|
+
if (heard.length < this.reply.length || droppedQueued) this.bargedSinceDispatch = true;
|
|
7150
7547
|
this.speaking = false;
|
|
7151
7548
|
this.ctxOpen = false;
|
|
7152
7549
|
this.interrupted = true;
|
|
@@ -7158,8 +7555,23 @@ var VoiceEngine = class _VoiceEngine {
|
|
|
7158
7555
|
if (this.reply) this.prevReply = this.reply;
|
|
7159
7556
|
this.setState("listening");
|
|
7160
7557
|
}
|
|
7558
|
+
/** How much of the reply the user heard at a cut (paused time is excluded by the sink's playedMs). Exact from the
|
|
7559
|
+
* TTS word timestamps the playback clock has reached (the ack's leading words skipped); without timestamps, an
|
|
7560
|
+
* estimate — played seconds × ~15 chars/s of TTS speech, coarse and often mid-word. */
|
|
7561
|
+
heardCut() {
|
|
7562
|
+
const played = Math.max(0, this.player.playedMs());
|
|
7563
|
+
if (!this.wordTexts.length) return { heard: this.reply.slice(0, Math.round(played / 1e3 * 15)), exact: false };
|
|
7564
|
+
let n = 0;
|
|
7565
|
+
while (n < this.wordStarts.length && this.wordStarts[n] <= played / 1e3) n++;
|
|
7566
|
+
if (!this.ctxOpen && n === this.wordStarts.length && played / 1e3 - this.wordStarts[n - 1] >= 0.4) return { heard: this.reply, exact: true };
|
|
7567
|
+
return { heard: this.reply.slice(0, heardPrefix(this.reply, this.wordTexts.slice(this.ackWords, n))), exact: true };
|
|
7568
|
+
}
|
|
7161
7569
|
stop() {
|
|
7162
7570
|
this.uttQueue = [];
|
|
7571
|
+
if (this.decideTimer) {
|
|
7572
|
+
this.clock.clearTimeout(this.decideTimer);
|
|
7573
|
+
this.decideTimer = null;
|
|
7574
|
+
}
|
|
7163
7575
|
this.clearAckTimer();
|
|
7164
7576
|
if (this.resumeTimer) this.clock.clearTimeout(this.resumeTimer);
|
|
7165
7577
|
if (this.pendingTimer) this.clock.clearTimeout(this.pendingTimer);
|
|
@@ -7172,9 +7584,41 @@ var VoiceEngine = class _VoiceEngine {
|
|
|
7172
7584
|
this.setState("idle");
|
|
7173
7585
|
}
|
|
7174
7586
|
// --- listening side (STT-driven) ---
|
|
7587
|
+
/** Echo-comparable tokens. Our text and the STT's rendering of it must tokenize alike: every non-alphanumeric
|
|
7588
|
+
* (em-dash, non-breaking hyphen, nbsp, …) SEPARATES words — deleting it glued "releases—GPT‑6" into
|
|
7589
|
+
* "releasesgpt6" and the echo gate missed our own words. Numbers take the spoken form STT may hear: letter/digit
|
|
7590
|
+
* runs split ("K2.8" → k 2 point 8), a decimal point is "point", number words are digits ("five" → 5).
|
|
7591
|
+
* Apostrophes are dropped (it's → its). Words under 2 letters are noise; digits are kept. Any script counts (Unicode
|
|
7592
|
+
* letters/marks/digits): an ASCII-only split gave Hebrew speech ZERO words, which the echo gate read as pure echo
|
|
7593
|
+
* (live 20260923-125143: "סבתא מחוץ לדלת?" and "תודה." swallowed). */
|
|
7175
7594
|
words(s) {
|
|
7176
|
-
return s.toLowerCase().replace(/[
|
|
7177
|
-
}
|
|
7595
|
+
return s.toLowerCase().replace(/['’]/g, "").replace(/(\d)\.(?=\d)/g, "$1 point ").replace(new RegExp("(\\p{L})(?=\\d)|(\\d)(?=\\p{L})", "gu"), "$1$2 ").split(/[^\p{L}\p{M}\p{N}]+/u).map((w) => _VoiceEngine.NUMBER_WORDS[w] ?? w).filter((w) => w.length >= 2 || /\d/.test(w));
|
|
7596
|
+
}
|
|
7597
|
+
static NUMBER_WORDS = Object.fromEntries(
|
|
7598
|
+
[
|
|
7599
|
+
"zero",
|
|
7600
|
+
"one",
|
|
7601
|
+
"two",
|
|
7602
|
+
"three",
|
|
7603
|
+
"four",
|
|
7604
|
+
"five",
|
|
7605
|
+
"six",
|
|
7606
|
+
"seven",
|
|
7607
|
+
"eight",
|
|
7608
|
+
"nine",
|
|
7609
|
+
"ten",
|
|
7610
|
+
"eleven",
|
|
7611
|
+
"twelve",
|
|
7612
|
+
"thirteen",
|
|
7613
|
+
"fourteen",
|
|
7614
|
+
"fifteen",
|
|
7615
|
+
"sixteen",
|
|
7616
|
+
"seventeen",
|
|
7617
|
+
"eighteen",
|
|
7618
|
+
"nineteen",
|
|
7619
|
+
"twenty"
|
|
7620
|
+
].map((w, i) => [w, String(i)])
|
|
7621
|
+
);
|
|
7178
7622
|
novelWords(text) {
|
|
7179
7623
|
return this.words(text).filter((w) => !this.echoWords.has(w));
|
|
7180
7624
|
}
|
|
@@ -7191,6 +7635,22 @@ var VoiceEngine = class _VoiceEngine {
|
|
|
7191
7635
|
const novel = this.novelWords(text).length;
|
|
7192
7636
|
return novel > 0 && novel / Math.max(1, total) > 0.5;
|
|
7193
7637
|
}
|
|
7638
|
+
/** Echo verdict for a FINAL in the echo window (AEC tier). Bag-of-words genuine() alone swallowed real replies
|
|
7639
|
+
* that reuse a few of our words — live 20260923-102258, right after a barge over "…Gemini 3.8 Flash…":
|
|
7640
|
+
* "Wait, there is a new Gemini model?" (3 novel / 6) was dropped twice and the user had to repeat it. Echo
|
|
7641
|
+
* of our own audio reproduces RUNS of what we said, in order (mis-hearings break a word, not the run); a
|
|
7642
|
+
* reply that shares vocabulary scatters it. So a ≥4-word final that fails the bag test is echo only if at
|
|
7643
|
+
* least half its words sit in a word pair we actually said. Shorter finals can't show order → bag rule. */
|
|
7644
|
+
echoFinal(text) {
|
|
7645
|
+
if (this.genuine(text)) return false;
|
|
7646
|
+
const w = this.words(text);
|
|
7647
|
+
if (w.length < 4) return true;
|
|
7648
|
+
const said = this.words(`${this.prevReply} ${this.reply} ${this.lastAck}`);
|
|
7649
|
+
const pairs = new Set(said.slice(1).map((x, i) => `${said[i]} ${x}`));
|
|
7650
|
+
const inRun = w.map(() => false);
|
|
7651
|
+
for (let i = 1; i < w.length; i++) if (pairs.has(`${w[i - 1]} ${w[i]}`)) inRun[i - 1] = inRun[i] = true;
|
|
7652
|
+
return inRun.filter(Boolean).length * 2 >= w.length;
|
|
7653
|
+
}
|
|
7194
7654
|
handlePartial(text) {
|
|
7195
7655
|
if (this.speaking) {
|
|
7196
7656
|
if (!this.options.bargeIn) return;
|
|
@@ -7202,14 +7662,29 @@ var VoiceEngine = class _VoiceEngine {
|
|
|
7202
7662
|
if (!this.echoActive() || (this.usingAec ? this.genuine(text) : this.novelWords(text).length >= 1)) this.options.onPartial(text);
|
|
7203
7663
|
return;
|
|
7204
7664
|
}
|
|
7665
|
+
if (this.echoesDispatch(text, true)) {
|
|
7666
|
+
if (this.lastDupPartial !== text.trim()) {
|
|
7667
|
+
this.lastDupPartial = text.trim();
|
|
7668
|
+
this.diag("dup_partial", { reason: "dup_final", text: text.slice(0, 60) });
|
|
7669
|
+
}
|
|
7670
|
+
if (this.pausedAt) {
|
|
7671
|
+
this.diag("overlap_resume", { heldMs: Math.round(this.clock.now() - this.pausedAt), reason: "dup_final" });
|
|
7672
|
+
this.resetOverlap(true);
|
|
7673
|
+
}
|
|
7674
|
+
return;
|
|
7675
|
+
}
|
|
7205
7676
|
if (this.overlapCapable) {
|
|
7206
7677
|
const txt = text.trim();
|
|
7207
7678
|
if (!txt || txt === this.lastOverlapPartial) return;
|
|
7208
7679
|
this.lastOverlapPartial = txt;
|
|
7209
7680
|
if (!this.genuine(txt)) {
|
|
7210
|
-
if (this.pausedAt) this.armResume();
|
|
7681
|
+
if (this.pausedAt && !this.decideTimer) this.armResume();
|
|
7211
7682
|
return;
|
|
7212
7683
|
}
|
|
7684
|
+
if (this.overlapKeptUntil && this.clock.now() < this.overlapKeptUntil) {
|
|
7685
|
+
if (!this.unjudged(txt)) return;
|
|
7686
|
+
this.overlapKeptUntil = 0;
|
|
7687
|
+
}
|
|
7213
7688
|
if (!this.pausedAt) {
|
|
7214
7689
|
this.pausedAt = this.clock.now();
|
|
7215
7690
|
const sinceResumeMs = this.lastResumeAt ? Math.round(this.clock.now() - this.lastResumeAt) : void 0;
|
|
@@ -7224,13 +7699,14 @@ var VoiceEngine = class _VoiceEngine {
|
|
|
7224
7699
|
}
|
|
7225
7700
|
}
|
|
7226
7701
|
if (this.words(txt).length >= 2) {
|
|
7227
|
-
|
|
7228
|
-
|
|
7229
|
-
|
|
7230
|
-
|
|
7702
|
+
if (this.options.overlapDecideMs > 0 && this.shortOverlap(txt)) {
|
|
7703
|
+
this.awaitOverlapDecision(txt);
|
|
7704
|
+
return;
|
|
7705
|
+
}
|
|
7706
|
+
this.cedeOverlap("overlap_cede", txt);
|
|
7231
7707
|
return;
|
|
7232
7708
|
}
|
|
7233
|
-
this.armResume();
|
|
7709
|
+
if (!this.decideTimer) this.armResume();
|
|
7234
7710
|
return;
|
|
7235
7711
|
}
|
|
7236
7712
|
const barge = this.usingAec ? this.genuine(text) : this.novelWords(text).length >= (this.suspectUntil ? 1 : 2);
|
|
@@ -7275,7 +7751,7 @@ var VoiceEngine = class _VoiceEngine {
|
|
|
7275
7751
|
this.specTimer = null;
|
|
7276
7752
|
if (this.speaking || this.specSpent || this.pendingUtt) return;
|
|
7277
7753
|
if (this.words(this.specPartial).length < this.options.speculativeMinWords) return;
|
|
7278
|
-
const flat = this.specPartial
|
|
7754
|
+
const flat = normWord(this.specPartial);
|
|
7279
7755
|
if (flat && this.lastDispatchFlat && this.clock.now() - this.lastDispatchAt < _VoiceEngine.DUP_FINAL_MS && (this.lastDispatchFlat.startsWith(flat) || flat.startsWith(this.lastDispatchFlat))) return;
|
|
7280
7756
|
this.specSpent = true;
|
|
7281
7757
|
this.specText = this.specPartial;
|
|
@@ -7314,7 +7790,7 @@ var VoiceEngine = class _VoiceEngine {
|
|
|
7314
7790
|
const letters = p.replace(/[^\p{L}]/gu, "");
|
|
7315
7791
|
const latin = p.replace(/[^A-Za-z]/g, "");
|
|
7316
7792
|
if (!letters || latin.length / letters.length < 0.7) return veto("non_latin");
|
|
7317
|
-
const flat = p
|
|
7793
|
+
const flat = normWord(p);
|
|
7318
7794
|
if (flat && this.lastDispatchFlat && this.clock.now() - this.lastDispatchAt < _VoiceEngine.DUP_FINAL_MS && (this.lastDispatchFlat.startsWith(flat) || flat.startsWith(this.lastDispatchFlat))) return;
|
|
7319
7795
|
this.fireBackchannel();
|
|
7320
7796
|
}, this.options.backchannelMs);
|
|
@@ -7402,6 +7878,10 @@ var VoiceEngine = class _VoiceEngine {
|
|
|
7402
7878
|
}
|
|
7403
7879
|
if (pfx && norm2(nw[pw.length - 1]).startsWith(norm2(pw[pw.length - 1]))) return next;
|
|
7404
7880
|
}
|
|
7881
|
+
if (_VoiceEngine.restates(prev, next)) {
|
|
7882
|
+
this.diag("merge_restated", { dropped: prev.slice(0, 60) });
|
|
7883
|
+
return next;
|
|
7884
|
+
}
|
|
7405
7885
|
const max = Math.min(pw.length, nw.length);
|
|
7406
7886
|
for (let k = max; k > 0; k--) {
|
|
7407
7887
|
let match = true;
|
|
@@ -7416,6 +7896,25 @@ var VoiceEngine = class _VoiceEngine {
|
|
|
7416
7896
|
static normWord(w) {
|
|
7417
7897
|
return normWord(w);
|
|
7418
7898
|
}
|
|
7899
|
+
/** `next` RESTATES the held, unfinished `prev` — the user restarted ("Wo- wait, can you explain more about" →
|
|
7900
|
+
* "What— wait, can you explain more about that? …", live 20260924-093945, where both landed in the dispatch).
|
|
7901
|
+
* Keep only the later version. Conservative: prev's words from index i ≤ 2 through its END reappear contiguously
|
|
7902
|
+
* in next starting at j ≤ 2, that run is ≥4 words and ≥75% of prev. i > 0 with j = 0 is NOT a restart — that is
|
|
7903
|
+
* an overlapping re-final continuing prev ("Tell me about the" + "about the weather"), deduped by the caller
|
|
7904
|
+
* keeping prev's head. prev's last word may be a char-prefix (a mid-word endpoint). */
|
|
7905
|
+
static restates(prev, next) {
|
|
7906
|
+
const p = prev.split(/\s+/).map(normWord).filter(Boolean), n = next.split(/\s+/).map(normWord).filter(Boolean);
|
|
7907
|
+
for (let i = 0; i <= 2; i++) {
|
|
7908
|
+
const k = p.length - i;
|
|
7909
|
+
if (k < 4 || k < 0.75 * p.length) return false;
|
|
7910
|
+
for (let j = i === 0 ? 0 : 1; j <= 2 && j + k <= n.length; j++) {
|
|
7911
|
+
let ok = true;
|
|
7912
|
+
for (let x = 0; x < k && ok; x++) ok = x === k - 1 ? n[j + x].startsWith(p[i + x]) : n[j + x] === p[i + x];
|
|
7913
|
+
if (ok) return true;
|
|
7914
|
+
}
|
|
7915
|
+
}
|
|
7916
|
+
return false;
|
|
7917
|
+
}
|
|
7419
7918
|
/** Soniox re-finalization of an ALREADY-DISPATCHED utterance, arriving PAST the merge window:
|
|
7420
7919
|
* the new final is a strict word-prefix superset of the last dispatch (the last dispatched word
|
|
7421
7920
|
* may be a char-prefix of the corresponding new word — a mid-word endpoint), and it lands within
|
|
@@ -7446,18 +7945,71 @@ var VoiceEngine = class _VoiceEngine {
|
|
|
7446
7945
|
if (!nSame.startsWith(pLast)) return false;
|
|
7447
7946
|
return nw.length > pw.length || nSame.length > pLast.length;
|
|
7448
7947
|
}
|
|
7948
|
+
/** The STT re-emitting (part of) the utterance just dispatched: ≥2 words that form a contiguous run of the dispatched
|
|
7949
|
+
* words, within DUP_FINAL_MS. A merged dispatch ("Okay." + "Um, check for the recent news in AI.") has its later
|
|
7950
|
+
* segment come back as a partial/final a beat later — the identical/superset guards miss a PIECE. A human cannot
|
|
7951
|
+
* re-speak a ≥2-word stretch of what they said within ~1s; one word ("stop") is never treated as a duplicate.
|
|
7952
|
+
* `partial`: the last word may be cut mid-word ("…news in A"). */
|
|
7953
|
+
echoesDispatch(text, partial = false) {
|
|
7954
|
+
if (!this.lastDispatchWords.length || this.clock.now() - this.lastDispatchAt >= _VoiceEngine.DUP_FINAL_MS) return false;
|
|
7955
|
+
const d = this.lastDispatchWords.map(normWord).filter(Boolean), w = text.trim().split(/\s+/).map(normWord).filter(Boolean);
|
|
7956
|
+
if (w.length < 2 || w.length > d.length) return false;
|
|
7957
|
+
const last = w.length - 1;
|
|
7958
|
+
outer: for (let i = 0; i + w.length <= d.length; i++) {
|
|
7959
|
+
for (let k = 0; k < w.length; k++) if (partial && k === last ? !d[i + k].startsWith(w[k]) : d[i + k] !== w[k]) continue outer;
|
|
7960
|
+
return true;
|
|
7961
|
+
}
|
|
7962
|
+
return false;
|
|
7963
|
+
}
|
|
7964
|
+
/** The agent replied since the dispatch with more than an acknowledgement (ACK_LEADING: "On it.", "Sure, one sec."). */
|
|
7965
|
+
answeredSinceDispatch() {
|
|
7966
|
+
return this.repliedSinceDispatch && !_VoiceEngine.ACK_LEADING.test(this.dispatchReply.replace(/[^a-z ]/gi, " ").replace(/\s+/g, " ").trim().toLowerCase());
|
|
7967
|
+
}
|
|
7968
|
+
lastDupPartial = "";
|
|
7969
|
+
// dup_partial diag once per distinct partial
|
|
7449
7970
|
static TRAIL_RE = /(?:^|\s)(?:and|but|or|so|to|the|a|an|of|in|for|with|that|if|uh|um|like|about|from|into|on|is|are|was|were|,)$/i;
|
|
7971
|
+
/** The user's line right after a barge cut our speech (or a re-final growing it), with no terminal punctuation:
|
|
7972
|
+
* they took the floor mid-thought — Soniox closes on the pause, not the thought (live 20260923-192543: "But isn't it"
|
|
7973
|
+
* → "…can be done" → "…with boxing" → "…or semantic?" dispatched 4 times). Held like an incomplete utterance. */
|
|
7974
|
+
unfinishedBargeLine(text) {
|
|
7975
|
+
const line = this.bargedSinceDispatch || this.bargedBeforeDispatch && this.refinalizes(text);
|
|
7976
|
+
return line && !/[.?!…]["'”’)\]]*$/.test(text.trim()) && !_VoiceEngine.CONTROL_RE.test(text.trim());
|
|
7977
|
+
}
|
|
7978
|
+
/** A short line that is complete on its own ("Stop", "Yes", "Okay, stop", "go on") — never held as unfinished. */
|
|
7979
|
+
static CONTROL_RE = ((w) => new RegExp(`^${w}(?:[\\s,]+${w})*[.!?\u2026]*$`, "i"))("(?:stop|wait|yes|yeah|yep|no|nope|okay|ok|go on|continue|never ?mind)");
|
|
7450
7980
|
/** The utterance sounds like the user paused mid-thought (trailing conjunction/filler/comma). */
|
|
7451
7981
|
looksIncomplete(text) {
|
|
7452
|
-
return _VoiceEngine.TRAIL_RE.test(text.trim());
|
|
7982
|
+
return _VoiceEngine.TRAIL_RE.test(text.trim()) && !_VoiceEngine.CONTROL_RE.test(text.trim());
|
|
7453
7983
|
}
|
|
7454
7984
|
handleUtterance(text) {
|
|
7455
|
-
if (this.speaking &&
|
|
7985
|
+
if (this.speaking && this.echoesDispatch(text)) {
|
|
7986
|
+
this.diag("utterance_drop", { reason: "dup_final", text: text.slice(0, 60) });
|
|
7987
|
+
if (this.pausedAt) {
|
|
7988
|
+
this.diag("overlap_resume", { heldMs: Math.round(this.clock.now() - this.pausedAt), reason: "dup_final" });
|
|
7989
|
+
this.resetOverlap(true);
|
|
7990
|
+
}
|
|
7991
|
+
return;
|
|
7992
|
+
}
|
|
7993
|
+
if (this.decideTimer) {
|
|
7994
|
+
this.decideHeldFinal = this.mergeUtterance(this.decideHeldFinal, text);
|
|
7995
|
+
this.diag("utterance_held", { reason: "overlap_decide", text: text.slice(0, 60) });
|
|
7996
|
+
return;
|
|
7997
|
+
}
|
|
7998
|
+
if (this.overlapKeptUntil) {
|
|
7999
|
+
const kept = this.clock.now() < this.overlapKeptUntil && !this.unjudged(text);
|
|
8000
|
+
this.overlapKeptUntil = 0;
|
|
8001
|
+
if (kept) {
|
|
8002
|
+
this.diag("utterance_drop", { reason: "overlap_kept", text: text.slice(0, 60) });
|
|
8003
|
+
return;
|
|
8004
|
+
}
|
|
8005
|
+
}
|
|
8006
|
+
const ownTail = !this.pausedAt && (this.clock.now() < this.bargeGraceUntil || this.refinalizes(text));
|
|
8007
|
+
if (this.speaking && (this.ctxOpen || this.pausedAt) && this.overlapCapable && !ownTail) {
|
|
7456
8008
|
this.diag("utterance_drop", { reason: "overlap_noise", text: text.slice(0, 60) });
|
|
7457
8009
|
this.stt.reset();
|
|
7458
8010
|
return;
|
|
7459
8011
|
}
|
|
7460
|
-
if (this.echoActive() && (!this.options.bargeIn || (this.usingAec ?
|
|
8012
|
+
if (this.echoActive() && (!this.options.bargeIn || (this.usingAec ? this.echoFinal(text) : this.novelWords(text).length < 2))) {
|
|
7461
8013
|
this.diag("echo_swallow", { guard: "echo_gate", text: text.slice(0, 60) });
|
|
7462
8014
|
this.stt.reset();
|
|
7463
8015
|
return;
|
|
@@ -7471,9 +8023,28 @@ var VoiceEngine = class _VoiceEngine {
|
|
|
7471
8023
|
if (this.pendingUtt) this.mergePath = "merged";
|
|
7472
8024
|
this.pendingUtt = this.mergeUtterance(this.pendingUtt, text);
|
|
7473
8025
|
if (this.pendingTimer) this.clock.clearTimeout(this.pendingTimer);
|
|
7474
|
-
if (this.options.
|
|
8026
|
+
if (this.options.adaptiveEndpointing) {
|
|
8027
|
+
const holdMs = computeHoldMs({
|
|
8028
|
+
transcript: this.pendingUtt,
|
|
8029
|
+
lastAiMessage: this.prevReply,
|
|
8030
|
+
baseMs: this.options.utteranceMergeMs,
|
|
8031
|
+
incompleteMs: this.options.incompleteMergeMs
|
|
8032
|
+
});
|
|
8033
|
+
const tier = endpointTier(this.pendingUtt);
|
|
8034
|
+
this.holdTier = tier;
|
|
8035
|
+
this.diag("hold", { reason: "adaptive", tier, punctuated: tier === 3, holdMs, tail: this.pendingUtt.slice(-40) });
|
|
8036
|
+
if (holdMs > this.options.utteranceMergeMs) {
|
|
8037
|
+
this.options.onHold();
|
|
8038
|
+
if (tier === 1 && this.options.holdFiller && !this.speaking) this.speakFiller(this.options.holdFiller);
|
|
8039
|
+
}
|
|
8040
|
+
if (holdMs <= 0) return this.flushUtterance();
|
|
8041
|
+
this.pendingTimer = this.clock.setTimeout(() => this.flushUtterance(), holdMs);
|
|
8042
|
+
return;
|
|
8043
|
+
}
|
|
8044
|
+
const bargeHold = !this.looksIncomplete(this.pendingUtt) && this.unfinishedBargeLine(this.pendingUtt);
|
|
8045
|
+
if (this.options.incompleteMergeMs && (bargeHold || this.looksIncomplete(this.pendingUtt))) {
|
|
7475
8046
|
log13.verbose(`hold: incomplete utterance "${this.pendingUtt.slice(-40)}"`);
|
|
7476
|
-
this.diag("hold", { reason: "incomplete", tail: this.pendingUtt.slice(-40) });
|
|
8047
|
+
this.diag("hold", { reason: bargeHold ? "barge_unfinished" : "incomplete", tail: this.pendingUtt.slice(-40) });
|
|
7477
8048
|
this.options.onHold();
|
|
7478
8049
|
if (this.options.holdFiller && !this.speaking) this.speakFiller(this.options.holdFiller);
|
|
7479
8050
|
this.pendingTimer = this.clock.setTimeout(() => this.flushUtterance(), this.options.incompleteMergeMs);
|
|
@@ -7504,9 +8075,13 @@ var VoiceEngine = class _VoiceEngine {
|
|
|
7504
8075
|
this.pendingUtt = "";
|
|
7505
8076
|
const path = this.mergePath;
|
|
7506
8077
|
this.mergePath = "direct";
|
|
8078
|
+
const heldTier = this.holdTier;
|
|
8079
|
+
this.holdTier = 0;
|
|
7507
8080
|
if (text) {
|
|
7508
|
-
const flat = text
|
|
7509
|
-
|
|
8081
|
+
const flat = normWord(text);
|
|
8082
|
+
const answered = this.answeredSinceDispatch();
|
|
8083
|
+
const part = flat !== this.lastDispatchFlat && !answered && this.echoesDispatch(text);
|
|
8084
|
+
if (part || flat && flat === this.lastDispatchFlat && !answered && this.clock.now() - this.lastDispatchAt < _VoiceEngine.DUP_FINAL_MS) {
|
|
7510
8085
|
log13.verbose(`dropped duplicate final "${text.slice(0, 40)}" (identical to the just-dispatched utterance, no reply between)`);
|
|
7511
8086
|
this.diag("utterance_drop", { reason: "dup_final", text: text.slice(0, 60) });
|
|
7512
8087
|
return;
|
|
@@ -7517,22 +8092,121 @@ var VoiceEngine = class _VoiceEngine {
|
|
|
7517
8092
|
this.diag("utterance_drop", { reason: "refinalized", text: text.slice(0, 60) });
|
|
7518
8093
|
this.lastDispatchFlat = flat;
|
|
7519
8094
|
this.lastDispatchWords = text.trim().split(/\s+/);
|
|
8095
|
+
this.lastDispatchText = text;
|
|
7520
8096
|
return;
|
|
7521
8097
|
}
|
|
8098
|
+
const meta = refinal ? { supersedes: this.lastDispatchText } : void 0;
|
|
7522
8099
|
this.lastDispatchFlat = flat;
|
|
7523
8100
|
this.lastDispatchWords = text.trim().split(/\s+/);
|
|
8101
|
+
this.lastDispatchText = text;
|
|
7524
8102
|
this.lastDispatchAt = this.clock.now();
|
|
7525
8103
|
this.repliedSinceDispatch = false;
|
|
8104
|
+
this.dispatchReply = "";
|
|
7526
8105
|
this.lastDispatchWasQuestion = /\?\s*$/.test(text);
|
|
8106
|
+
this.bargedBeforeDispatch = refinal ? this.bargedBeforeDispatch || this.bargedSinceDispatch : this.bargedSinceDispatch;
|
|
8107
|
+
this.bargedSinceDispatch = false;
|
|
8108
|
+
this.dispatchRefinal = refinal;
|
|
8109
|
+
if (!refinal) this.ackSpent = false;
|
|
7527
8110
|
this.turnStartAt = this.clock.now();
|
|
7528
8111
|
this.bargeGraceUntil = this.clock.now() + this.options.bargeGraceMs;
|
|
7529
|
-
this.diag("dispatch", { text, path: refinal ? "refinalized" : path, question: this.lastDispatchWasQuestion });
|
|
7530
|
-
this.options.onUtterance(text);
|
|
8112
|
+
this.diag("dispatch", { text, path: refinal ? "refinalized" : path, heldTier, question: this.lastDispatchWasQuestion, ...this.bargedBeforeDispatch ? { barged: true } : {} });
|
|
8113
|
+
this.options.onUtterance(text, meta);
|
|
7531
8114
|
}
|
|
7532
8115
|
}
|
|
7533
8116
|
get overlapCapable() {
|
|
7534
8117
|
return this.usingAec && this.options.overlapPause && !!this.player.pause && !!this.player.resume;
|
|
7535
8118
|
}
|
|
8119
|
+
/** Stop/question words that make an overlap an obvious barge — never held for a host decision. */
|
|
8120
|
+
static OVERLAP_CEDE_WORDS = /* @__PURE__ */ new Set([
|
|
8121
|
+
"stop",
|
|
8122
|
+
"wait",
|
|
8123
|
+
"no",
|
|
8124
|
+
"hold",
|
|
8125
|
+
"hang",
|
|
8126
|
+
"pause",
|
|
8127
|
+
"quiet",
|
|
8128
|
+
"enough",
|
|
8129
|
+
"shut",
|
|
8130
|
+
"cancel",
|
|
8131
|
+
"what",
|
|
8132
|
+
"why",
|
|
8133
|
+
"how",
|
|
8134
|
+
"who",
|
|
8135
|
+
"when",
|
|
8136
|
+
"where",
|
|
8137
|
+
"which"
|
|
8138
|
+
]);
|
|
8139
|
+
/** A short, non-urgent overlap: the kind a listener cue or reaction takes (see overlapDecideMs). */
|
|
8140
|
+
shortOverlap(text) {
|
|
8141
|
+
const w = this.words(text);
|
|
8142
|
+
return w.length <= this.options.overlapDecideMaxWords && !/\?/.test(text) && !w.some((x) => _VoiceEngine.OVERLAP_CEDE_WORDS.has(x));
|
|
8143
|
+
}
|
|
8144
|
+
/** Cede the floor to an overlap: interrupt, tell the host, then dispatch a final that was held while deciding. */
|
|
8145
|
+
cedeOverlap(trigger, txt) {
|
|
8146
|
+
const phase = this.ctxOpen ? "speaking" : "drain";
|
|
8147
|
+
const held = this.decideHeldFinal;
|
|
8148
|
+
this.decideHeldFinal = "";
|
|
8149
|
+
this.diag("barge_in", { phase, trigger, novel: this.novelWords(txt).length, total: this.words(txt).length });
|
|
8150
|
+
this.interrupt();
|
|
8151
|
+
this.options.onBargeIn(phase);
|
|
8152
|
+
if (held) this.handleUtterance(held);
|
|
8153
|
+
}
|
|
8154
|
+
/** Wait (bounded) for the host's verdict. `fresh` = a re-decision on grown text: it gets its own window — the host
|
|
8155
|
+
* was only just asked about it (live: "Oh," → "Oh, that" → "Oh, that— that's new" outgrew every verdict, and the
|
|
8156
|
+
* first window ran out mid-chase, ceding a reaction Jev kept) — but the whole hold is capped at 3× the window. */
|
|
8157
|
+
awaitOverlapDecision(txt, fresh = false) {
|
|
8158
|
+
if (this.decideTimer && !fresh) return;
|
|
8159
|
+
if (this.resumeTimer) {
|
|
8160
|
+
this.clock.clearTimeout(this.resumeTimer);
|
|
8161
|
+
this.resumeTimer = null;
|
|
8162
|
+
}
|
|
8163
|
+
if (this.decideTimer) this.clock.clearTimeout(this.decideTimer);
|
|
8164
|
+
const now3 = this.clock.now(), ms = this.options.overlapDecideMs;
|
|
8165
|
+
if (!this.decideSince) this.decideSince = now3;
|
|
8166
|
+
const wait = Math.min(ms, this.decideSince + 3 * ms - now3);
|
|
8167
|
+
this.diag("overlap_await", { text: txt.slice(0, 60), ms: wait });
|
|
8168
|
+
this.decideTimer = this.clock.setTimeout(() => {
|
|
8169
|
+
this.decideTimer = null;
|
|
8170
|
+
if (this.pausedAt) this.cedeOverlap("overlap_decide_timeout", this.lastOverlapPartial || txt);
|
|
8171
|
+
}, Math.max(0, wait));
|
|
8172
|
+
}
|
|
8173
|
+
/** Words in `text` the host's keep_talking verdict never covered (the overlap grew after it judged). */
|
|
8174
|
+
unjudged(text) {
|
|
8175
|
+
const seen = new Set(this.words(this.overlapKept));
|
|
8176
|
+
return this.words(text).some((w) => !seen.has(w));
|
|
8177
|
+
}
|
|
8178
|
+
/** The host's verdict on the overlap the engine is holding (overlapDecideMs, or any live overlap pause):
|
|
8179
|
+
* 'yield' cedes the floor (a final that landed meanwhile is dispatched); 'keep_talking' resumes playback and
|
|
8180
|
+
* absorbs the rest of THAT reaction — `heard`, the text the verdict judged (default: the live partial); its final
|
|
8181
|
+
* is dropped. Words beyond `heard` are a new overlap, decided afresh: if the partial/final has already grown past
|
|
8182
|
+
* it, the hold stays and `overlap_pause` is re-emitted for a new verdict (bounded by overlapDecideMs, else it
|
|
8183
|
+
* cedes; a re-decision gets its own window, the whole hold capped at 3× it) and this returns false. Also false when no overlap hold is live (the engine already decided). */
|
|
8184
|
+
decideOverlap(choice, heard) {
|
|
8185
|
+
if (!this.pausedAt || !this.speaking) return false;
|
|
8186
|
+
if (choice === "yield") {
|
|
8187
|
+
this.cedeOverlap("host_yield", this.lastOverlapPartial);
|
|
8188
|
+
return true;
|
|
8189
|
+
}
|
|
8190
|
+
this.overlapKept = heard?.trim() || this.lastOverlapPartial;
|
|
8191
|
+
const grown = this.decideHeldFinal || this.lastOverlapPartial;
|
|
8192
|
+
if (this.unjudged(grown)) {
|
|
8193
|
+
if (!(this.options.overlapDecideMs > 0 && this.shortOverlap(grown))) {
|
|
8194
|
+
this.cedeOverlap("overlap_cede", grown);
|
|
8195
|
+
return false;
|
|
8196
|
+
}
|
|
8197
|
+
this.diag("overlap_pause", { text: grown.slice(0, 60), redecide: true });
|
|
8198
|
+
this.awaitOverlapDecision(grown, true);
|
|
8199
|
+
return false;
|
|
8200
|
+
}
|
|
8201
|
+
const held = this.decideHeldFinal;
|
|
8202
|
+
this.decideHeldFinal = "";
|
|
8203
|
+
this.diag("overlap_resume", { heldMs: Math.round(this.clock.now() - this.pausedAt), reason: "host_keep" });
|
|
8204
|
+
if (held) this.diag("utterance_drop", { reason: "overlap_kept", text: held.slice(0, 60) });
|
|
8205
|
+
this.overlapKeptUntil = held ? 0 : this.clock.now() + 4e3;
|
|
8206
|
+
this.resetOverlap(true);
|
|
8207
|
+
this.lastResumeAt = 0;
|
|
8208
|
+
return true;
|
|
8209
|
+
}
|
|
7536
8210
|
armResume() {
|
|
7537
8211
|
if (this.resumeTimer) this.clock.clearTimeout(this.resumeTimer);
|
|
7538
8212
|
this.resumeTimer = this.clock.setTimeout(() => {
|
|
@@ -7548,6 +8222,20 @@ var VoiceEngine = class _VoiceEngine {
|
|
|
7548
8222
|
this.clock.clearTimeout(this.resumeTimer);
|
|
7549
8223
|
this.resumeTimer = null;
|
|
7550
8224
|
}
|
|
8225
|
+
if (this.decideTimer) {
|
|
8226
|
+
this.clock.clearTimeout(this.decideTimer);
|
|
8227
|
+
this.decideTimer = null;
|
|
8228
|
+
}
|
|
8229
|
+
this.decideSince = 0;
|
|
8230
|
+
const held = this.decideHeldFinal;
|
|
8231
|
+
this.decideHeldFinal = "";
|
|
8232
|
+
if (held) {
|
|
8233
|
+
this.diag("utterance_release", { reason: "overlap_hold_exit", text: held.slice(0, 60) });
|
|
8234
|
+
this.pendingUtt = this.mergeUtterance(this.pendingUtt, held);
|
|
8235
|
+
if (this.pendingTimer) this.clock.clearTimeout(this.pendingTimer);
|
|
8236
|
+
this.pendingTimer = this.clock.setTimeout(() => this.flushUtterance(), 0);
|
|
8237
|
+
}
|
|
8238
|
+
if (this.pausedAt) this.diag("overlap_end", { resumed: resume, heldMs: Math.round(this.clock.now() - this.pausedAt) });
|
|
7551
8239
|
if (this.pausedAt && resume) {
|
|
7552
8240
|
this.player.resume?.();
|
|
7553
8241
|
this.lastResumeAt = this.clock.now();
|
|
@@ -7613,7 +8301,8 @@ var SentenceCoalescer = class _SentenceCoalescer {
|
|
|
7613
8301
|
feed(delta) {
|
|
7614
8302
|
if (delta) this.buf += delta;
|
|
7615
8303
|
let cut = -1;
|
|
7616
|
-
|
|
8304
|
+
const decimal = (i) => this.buf[i] === "." && /\d/.test(this.buf[i - 1] ?? "") && (i + 1 === this.buf.length || /\d/.test(this.buf[i + 1]));
|
|
8305
|
+
for (let i = 0; i < this.buf.length; i++) if (_SentenceCoalescer.isEnd(this.buf[i]) && !decimal(i)) cut = i;
|
|
7617
8306
|
if (cut < 0) return "";
|
|
7618
8307
|
if (this.buf[cut] !== "\n") while (cut + 1 < this.buf.length && CLOSERS.includes(this.buf[cut + 1])) cut++;
|
|
7619
8308
|
const ready = this.buf.slice(0, cut + 1).trim();
|
|
@@ -7628,9 +8317,14 @@ var SentenceCoalescer = class _SentenceCoalescer {
|
|
|
7628
8317
|
};
|
|
7629
8318
|
var SpokenSplitter = class {
|
|
7630
8319
|
buf = "";
|
|
7631
|
-
|
|
8320
|
+
open = false;
|
|
7632
8321
|
/** True once any spoken char has ever been emitted (drives the no-spoken fallback). */
|
|
7633
8322
|
spokeAny = false;
|
|
8323
|
+
/** Inside a `<spoken>` block after the last feed. Once it is false, a caller coalescing sentences can flush: nothing
|
|
8324
|
+
* more of that block is coming (a held "…in 2026." would otherwise wait for the next spoken text or the stream end). */
|
|
8325
|
+
get inSpoken() {
|
|
8326
|
+
return this.open;
|
|
8327
|
+
}
|
|
7634
8328
|
/** Feed a delta; returns the spoken/detail spans completed by this chunk (either may be ''). */
|
|
7635
8329
|
feed(delta) {
|
|
7636
8330
|
this.buf += delta;
|
|
@@ -7644,26 +8338,26 @@ var SpokenSplitter = class {
|
|
|
7644
8338
|
let spoken = "";
|
|
7645
8339
|
let detail = "";
|
|
7646
8340
|
while (this.buf.length) {
|
|
7647
|
-
const tag = this.
|
|
8341
|
+
const tag = this.open ? CLOSE : OPEN;
|
|
7648
8342
|
const idx = this.buf.indexOf(tag);
|
|
7649
8343
|
if (idx >= 0) {
|
|
7650
8344
|
const text2 = this.buf.slice(0, idx);
|
|
7651
|
-
if (this.
|
|
8345
|
+
if (this.open) spoken += text2;
|
|
7652
8346
|
else detail += text2;
|
|
7653
8347
|
this.buf = this.buf.slice(idx + tag.length);
|
|
7654
|
-
this.
|
|
8348
|
+
this.open = !this.open;
|
|
7655
8349
|
continue;
|
|
7656
8350
|
}
|
|
7657
8351
|
const lt = this.buf.lastIndexOf("<");
|
|
7658
8352
|
const holdStart = lt >= 0 && tag.startsWith(this.buf.slice(lt)) ? lt : this.buf.length;
|
|
7659
8353
|
const text = this.buf.slice(0, holdStart);
|
|
7660
|
-
if (this.
|
|
8354
|
+
if (this.open) spoken += text;
|
|
7661
8355
|
else detail += text;
|
|
7662
8356
|
this.buf = this.buf.slice(holdStart);
|
|
7663
8357
|
break;
|
|
7664
8358
|
}
|
|
7665
8359
|
if (final && this.buf) {
|
|
7666
|
-
if (this.
|
|
8360
|
+
if (this.open) spoken += this.buf;
|
|
7667
8361
|
else detail += this.buf;
|
|
7668
8362
|
this.buf = "";
|
|
7669
8363
|
}
|
|
@@ -7678,7 +8372,11 @@ var now = () => performance.now();
|
|
|
7678
8372
|
var SonioxSTTOptions = class {
|
|
7679
8373
|
auth = "";
|
|
7680
8374
|
source;
|
|
7681
|
-
|
|
8375
|
+
/** `stt-rt-preview` was RETIRED (aliased to the two-generations-old v3 on 2025-10-31), so every
|
|
8376
|
+
* caller that never set this was silently running an old model while the alias kept it working —
|
|
8377
|
+
* the failure mode of a default that is a moving vendor pointer. Pin a real version instead;
|
|
8378
|
+
* `stt-rt-v5` is live today (verified against GET https://api.soniox.com/v1/models). */
|
|
8379
|
+
model = "stt-rt-v5";
|
|
7682
8380
|
languageHints = ["en"];
|
|
7683
8381
|
/** Client-side endpoint: finalized text + no new tokens for this long = utterance (don't wait for
|
|
7684
8382
|
* Soniox's semantic <end>, which adds 0.5-1.5s — the difference between ping-pong and lag). */
|
|
@@ -8051,6 +8749,15 @@ function base64ToBytes(b64) {
|
|
|
8051
8749
|
return out;
|
|
8052
8750
|
}
|
|
8053
8751
|
|
|
8752
|
+
// node_modules/@bod.ee/voice/src/adapters/fish.ts
|
|
8753
|
+
var log16 = forComponent2("FishTTS");
|
|
8754
|
+
|
|
8755
|
+
// node_modules/@bod.ee/voice/src/adapters/grok.ts
|
|
8756
|
+
var log17 = forComponent2("GrokTTS");
|
|
8757
|
+
|
|
8758
|
+
// node_modules/@bod.ee/voice/src/adapters/grok-stt.ts
|
|
8759
|
+
var log18 = forComponent2("GrokSTT");
|
|
8760
|
+
|
|
8054
8761
|
// src/voice/engine.ts
|
|
8055
8762
|
init_logging();
|
|
8056
8763
|
configureLogging((name) => {
|
|
@@ -8059,8 +8766,158 @@ configureLogging((name) => {
|
|
|
8059
8766
|
return { debug: at("debug"), verbose: at("verbose"), info: at("info"), warn: at("warn"), error: at("error") };
|
|
8060
8767
|
});
|
|
8061
8768
|
|
|
8769
|
+
// src/voice/instinct.ts
|
|
8770
|
+
init_logging();
|
|
8771
|
+
var log19 = forComponent("Instinct");
|
|
8772
|
+
var ACK_SITUATIONS = {
|
|
8773
|
+
hard_question: `a question that takes real thought, judgment or looking something up ("what's the biggest takeaway?", "how should I structure this?")`,
|
|
8774
|
+
simple_question: 'a quick question with an instant, obvious answer ("how are you?", "what time is it?", "can you hear me?")',
|
|
8775
|
+
emotional_bad: "they share something bad, sad, painful, worrying or frustrating",
|
|
8776
|
+
emotional_good: "they share good news, excitement, relief or pride",
|
|
8777
|
+
challenge: `they doubt, question or push back on what you just said ("are you sure?", "why do you say that?", "that doesn't sound right")`,
|
|
8778
|
+
surprise: `they react with surprise or disbelief to what you said ("oh, that's new", "really?", "no way", "oh.")`,
|
|
8779
|
+
agreement: `they agree with you, confirm, or tell you to go on ("exactly", "yes, that's it", "okay, so continue")`,
|
|
8780
|
+
goodbye: "they are wrapping up the conversation or saying goodbye",
|
|
8781
|
+
command: 'a request or instruction for you to do, find, check or tell them something ("tell me the news", "check my calendar")',
|
|
8782
|
+
silent: 'ONLY when nothing was really said yet: an unfinished fragment or false start, a mic check or count ("one, two"), a bare greeting or filler ("hey", "um")'
|
|
8783
|
+
};
|
|
8784
|
+
var INTENT_ACK_OVER = {
|
|
8785
|
+
stop: Object.keys(ACK_SITUATIONS).filter((s) => s !== "silent"),
|
|
8786
|
+
steer: ["command"],
|
|
8787
|
+
resume: ["agreement", "silent"],
|
|
8788
|
+
aside: []
|
|
8789
|
+
};
|
|
8790
|
+
function interruptionNote(cut, intent) {
|
|
8791
|
+
const full = cut.full.trim();
|
|
8792
|
+
let at = Math.min(cut.heard.length, cut.full.length);
|
|
8793
|
+
if (/\S/.test(cut.full[at] ?? "") && /\S/.test(cut.full[at - 1] ?? "")) at = cut.full.lastIndexOf(" ", at) + 1;
|
|
8794
|
+
const heardWords = cut.full.slice(0, at).trim().split(/\s+/).filter(Boolean).length;
|
|
8795
|
+
const unheard = cut.full.slice(at).trim();
|
|
8796
|
+
const landed = heardWords < 4 ? "They cut in almost at once \u2014 treat all of it as unsaid." : `They heard everything before this part. They missed the rest of it: "${unheard}".`;
|
|
8797
|
+
const how = {
|
|
8798
|
+
resume: "They did not ask anything new. Carry on with the part they missed \u2014 no restart, no apology.",
|
|
8799
|
+
aside: "It was an aside. Answer it briefly FIRST, then finish the part they missed, adjusted to anything they just said. Say each point once: skip whatever they already heard and whatever your answer just covered.",
|
|
8800
|
+
steer: "They steered away. Follow their new direction; carry over only what still matters from your unfinished point, or drop it.",
|
|
8801
|
+
stop: "They want you to stop. Do not resume; at most a few words of acknowledgement.",
|
|
8802
|
+
unknown: "Decide like a person would: if it was an aside, answer it and then finish your point; if they steered away, follow them and drop or adapt the rest."
|
|
8803
|
+
};
|
|
8804
|
+
return `
|
|
8805
|
+
[the user interrupted you mid-speech. You were saying: "${full}" ${landed} ${how[intent ?? "unknown"]} Just talk, like a person picking the thread back up: never tell them what they heard or missed, never say where you were cut off, and never quote your earlier words back. If their words are still an unfinished fragment, call Hold instead of answering.]`;
|
|
8806
|
+
}
|
|
8807
|
+
|
|
8808
|
+
// src/voice/unsaid.ts
|
|
8809
|
+
init_logging();
|
|
8810
|
+
var log20 = forComponent("Unsaid");
|
|
8811
|
+
var STOP2 = new Set("that this with have from they them their there then than what when were will would your about into just also been some only over more most very which while after before because these those does here".split(" "));
|
|
8812
|
+
var contentStems = (s) => (s.toLowerCase().match(/[a-z0-9']+/g) ?? []).filter((w) => w.length >= 4 && !STOP2.has(w)).map((w) => w.slice(0, 4));
|
|
8813
|
+
var SAID_OVERLAP = 0.6;
|
|
8814
|
+
var UNSAID_OVERLAP = 0.25;
|
|
8815
|
+
var CONVEYED_MIN = 0.3;
|
|
8816
|
+
function splitClauses(text) {
|
|
8817
|
+
return text.split(/(?<=[.!?…])\s+|(?<=;)\s+|(?<=,)\s+(?=(?:and|but)\b)/i).map((s) => s.trim()).filter((s) => contentStems(s).length > 0);
|
|
8818
|
+
}
|
|
8819
|
+
var overlap = (clause, said) => {
|
|
8820
|
+
const w = contentStems(clause);
|
|
8821
|
+
return w.filter((x) => said.has(x)).length / w.length;
|
|
8822
|
+
};
|
|
8823
|
+
var INSTRUCTIONS = "A voice assistant was interrupted mid-delivery; ASSISTANT_SAID is what it then said aloud. Did the MAIN POINT of this clause of the unheard delivery reach the listener through ASSISTANT_SAID \u2014 would hearing the clause too add nothing important? Different words, casual phrasing, rounder numbers, or a missing minor detail (a name, an exact figure) still count as conveyed. Not conveyed only when the clause's main point is absent from ASSISTANT_SAID, even if a neighbouring point is there.";
|
|
8824
|
+
async function unsaidClauses(pieces, spoken, decider, timeoutMs = 5e3) {
|
|
8825
|
+
const said = new Set(contentStems(spoken));
|
|
8826
|
+
const open = splitClauses(pieces.join(" ")).map((c) => ({ c, r: overlap(c, said) })).filter((x) => x.r < SAID_OVERLAP);
|
|
8827
|
+
if (!open.length) return [];
|
|
8828
|
+
const fallback = () => open.filter((x) => x.r < UNSAID_OVERLAP).map((x) => x.c);
|
|
8829
|
+
if (!decider) return fallback();
|
|
8830
|
+
const state = { assistant_said: spoken };
|
|
8831
|
+
const questions = {};
|
|
8832
|
+
open.forEach((x, i) => {
|
|
8833
|
+
state[`c${i}`] = x.c;
|
|
8834
|
+
questions[`c${i}`] = { type: "noul", instructions: `${INSTRUCTIONS} The clause is c${i}.`, criteria: { true: `the main point of c${i} reached the listener`, false: `the main point of c${i} is missing` } };
|
|
8835
|
+
});
|
|
8836
|
+
let timer;
|
|
8837
|
+
try {
|
|
8838
|
+
const { answers } = await Promise.race([
|
|
8839
|
+
decider.decide({ state, questions }),
|
|
8840
|
+
new Promise((_, rej) => {
|
|
8841
|
+
timer = setTimeout(() => rej(new Error(`no verdict in ${timeoutMs}ms`)), timeoutMs);
|
|
8842
|
+
})
|
|
8843
|
+
]);
|
|
8844
|
+
return open.filter((_, i) => {
|
|
8845
|
+
const p = Number(answers[`c${i}`]?.noul);
|
|
8846
|
+
if (!Number.isFinite(p)) throw new Error(`no noul for c${i}`);
|
|
8847
|
+
return p < CONVEYED_MIN;
|
|
8848
|
+
}).map((x) => x.c);
|
|
8849
|
+
} catch (e) {
|
|
8850
|
+
log20.warn(`unsaid judge failed \u2014 only near-zero-overlap clauses count as unsaid: ${e.message}`);
|
|
8851
|
+
return fallback();
|
|
8852
|
+
} finally {
|
|
8853
|
+
clearTimeout(timer);
|
|
8854
|
+
}
|
|
8855
|
+
}
|
|
8856
|
+
|
|
8857
|
+
// src/voice/leak.ts
|
|
8858
|
+
var TOOL = String.raw`(?:QuickLook|TaskStatus|CancelTask|AnswerTask|ExitSession|Act|Think|Hold(?! on\b))`;
|
|
8859
|
+
var SUSPECT = [
|
|
8860
|
+
/\b(?:user|assistant)\b/i,
|
|
8861
|
+
/\b(?:instructions?|guidelines?|polic(?:y|ies)|system prompt|developer (?:message|instructions?|prompt))\b/i,
|
|
8862
|
+
new RegExp(String.raw`\b${TOOL}\b`),
|
|
8863
|
+
/\bwe (?:need to|have to|should|must|ought to)\b|(?:^|[.!?]\s*)(?:need to|should|must|let's)\b/i,
|
|
8864
|
+
/\bi (?:need to|have to|should|must) (?:respond|reply|answer|output|produce|comply|acknowledge|say|ask|greet|call|use|dispatch|wait|keep|avoid|not)\b/i,
|
|
8865
|
+
/\b(?:respond|reply|output|produce|comply|acknowledge?)\w*\b/i,
|
|
8866
|
+
/\bwe(?: have|'ve)\b|\bwe (?:already )?(?:responded|replied|dispatched|used|called|did|gave|said|asked)\b/i,
|
|
8867
|
+
/\bno (?:further|more|other)\b/i,
|
|
8868
|
+
/\[\s*task\b|\bt\d+\b|\bwait(?:ing)? for (?:the )?(?:events?|results?|task)\b/i,
|
|
8869
|
+
/\w\s*\{\s*"|\bwhat:\s*"/,
|
|
8870
|
+
/\b(?:last|previous) (?:response|reply|message|turn)\b|\bthe (?:conversation|transcript|context)\b/i
|
|
8871
|
+
];
|
|
8872
|
+
var STRICT = [
|
|
8873
|
+
// the listener as a third-person subject opening the sentence ("The user wants…", telegraphic "User wants…")
|
|
8874
|
+
/^(?:the )?user(?:'s)? (?:asks|asked|wants|wanted|said|says|is (?:asking|saying|speaking|referring|requesting)|just (?:said|asked)|requests|requested|mentioned|seems|keeps|repeated|typed|provided|expects|explicitly)\b/i,
|
|
8875
|
+
/\b(?:if|when|since|because) user (?:is|keeps|wants|says|said|asks|asked|seems)\b/i,
|
|
8876
|
+
/\bwe have (?:an? )?(?:user (?:who|request|message|asking|saying)|conversation (?:where|with)|new user message)\b|^we have task\b/i,
|
|
8877
|
+
/\bthe assistant(?:'s (?:context|last|previous) (?:response|reply|turn|message)| (?:responded|replied) with)\b/i,
|
|
8878
|
+
// planning its own reply
|
|
8879
|
+
/\b(?:we|i) (?:need to|have to|should|must) (?:respond|reply|answer)(?:\s*:|\s+(?:briefly|concisely|accordingly|according to|appropriately|to the user|with (?:a |the )?(?:brief|short|result|acknowledg)))/i,
|
|
8880
|
+
/\b(?:we|i) (?:need to|should|must) output\b|^(?:we|i) must comply[.!]?$/i,
|
|
8881
|
+
/\bno (?:further|more|other) (?:response|reply|output)s? (?:is )?(?:needed|required|necessary)\b/i,
|
|
8882
|
+
/\bwe(?: have|'ve)? (?:already )?(?:responded|replied)[.!]?$/i,
|
|
8883
|
+
// its own protocol: using its tools, tool-call syntax, task events
|
|
8884
|
+
new RegExp(String.raw`(?:\b[Ww]e(?: need to| should| must| have to| will|'ll)|\b[Ll]et'?s|^[Ss]hould|^[Nn]ow we should) (?:call|use|do|dispatch(?: to)?|invoke) (?:the )?${TOOL}\b`),
|
|
8885
|
+
new RegExp(String.raw`\b(?:[Ww]e (?:called|dispatched)|responded with) ${TOOL}\b|\b${TOOL}\s*\{|\bwhat:\s*"|\b${TOOL} (?:tool )?(?:brief|call|args?|arguments)\b`),
|
|
8886
|
+
/\bwait(?:ing)? for (?:the )?(?:events?|t\d+)\b|\bt\d+ (?:completed|failed)\b|\[\s*task\b/i
|
|
8887
|
+
];
|
|
8888
|
+
var SENTENCE_END = new RegExp(`[.!?\u2026]+["')\\]]*(?:\\s|(?=\\p{Lu}|\\[))`, "gu");
|
|
8889
|
+
var SENTENCE_SPLIT = new RegExp(`(?<=[.!?\u2026]+["')\\]]*)(?:\\s+|(?=\\p{Lu}|\\[))`, "u");
|
|
8890
|
+
var clean2 = (s) => s.replace(/\[[a-z_ ]+\]\s*/gi, "").trim();
|
|
8891
|
+
function isLeakSuspect(sentence) {
|
|
8892
|
+
const s = clean2(sentence);
|
|
8893
|
+
return SUSPECT.some((re) => re.test(s));
|
|
8894
|
+
}
|
|
8895
|
+
function isStrictLeak(sentence) {
|
|
8896
|
+
return sentence.split(SENTENCE_SPLIT).some((x) => {
|
|
8897
|
+
const s = clean2(x);
|
|
8898
|
+
return STRICT.some((re) => re.test(s));
|
|
8899
|
+
});
|
|
8900
|
+
}
|
|
8901
|
+
var JUDGE_INSTRUCTIONS = `This sentence was produced by a voice assistant's reply stream and is about to be read aloud to the user. Is it the assistant's private scratchpad reasoning that leaked out \u2014 talking ABOUT the conversation instead of TO the listener? Leaked reasoning: refers to the listener in the third person ("the user wants\u2026", "user said\u2026"), plans its own reply ("we need to respond\u2026", "we should output\u2026", "I should answer briefly"), cites its own guidelines/instructions, or narrates its internal tool calls and task events (QuickLook, Act, Think, Hold, TaskStatus, "t2 completed") as steps it is taking. Speech: anything said TO the listener \u2014 answers, questions, offers, news, advice, explaining what it can do when asked, even if it mentions a user, an assistant, a policy, instructions, replying, or a tool.`;
|
|
8902
|
+
var LEAK_JUDGE_THRESHOLD = 0.6;
|
|
8903
|
+
async function judgeLeak(decider, sentence, userSaid) {
|
|
8904
|
+
const { answers } = await decider.decide({
|
|
8905
|
+
state: { what_the_user_just_said: userSaid.slice(-600) || null, sentence_about_to_be_spoken: clean2(sentence) },
|
|
8906
|
+
questions: {
|
|
8907
|
+
q: {
|
|
8908
|
+
type: "noul",
|
|
8909
|
+
instructions: JUDGE_INSTRUCTIONS,
|
|
8910
|
+
criteria: { true: "leaked private reasoning \u2014 must not be spoken", false: "said to the listener \u2014 fine to speak" }
|
|
8911
|
+
}
|
|
8912
|
+
}
|
|
8913
|
+
});
|
|
8914
|
+
const p = Number(answers.q.noul);
|
|
8915
|
+
if (!Number.isFinite(p)) throw new Error("leak judge: no noul in the answer");
|
|
8916
|
+
return p;
|
|
8917
|
+
}
|
|
8918
|
+
|
|
8062
8919
|
// src/duplex.ts
|
|
8063
|
-
var
|
|
8920
|
+
var log21 = forComponent("DuplexAgent");
|
|
8064
8921
|
function describeCall(call) {
|
|
8065
8922
|
const v = call.args && Object.values(call.args).find((x) => typeof x === "string" && x.trim());
|
|
8066
8923
|
const hint = v ? ` (${String(v).replace(/\s+/g, " ").trim().slice(0, 48)})` : "";
|
|
@@ -8102,6 +8959,13 @@ var DuplexAgentOptions = class {
|
|
|
8102
8959
|
/** Teach the model to emit inline `[emotion]` tags for Cartesia emotion control. Only set when the
|
|
8103
8960
|
* TTS actually speaks them — text-duplex (no TTS) would otherwise print literal tags. */
|
|
8104
8961
|
emotionTags = false;
|
|
8962
|
+
/** Judge a post-barge line while a delivery is parked: resume it now, answer an aside and carry it on (the reflex,
|
|
8963
|
+
* told what was heard — see asideNote), or drop it (steer/stop). Unset → the conservative `isTrivialBarge` allowlist (resume | steer); a throwing judge
|
|
8964
|
+
* falls back to it too. */
|
|
8965
|
+
bargeIntent;
|
|
8966
|
+
/** Typed-decision model (e.g. ai.libx.js DecideClient → Jev). Judges reflex sentences the leak screen flags as
|
|
8967
|
+
* SUSPECT (src/voice/leak.ts) — only suspects pay the ~300ms call. Unset → only the strict leak shapes are cut. */
|
|
8968
|
+
decider;
|
|
8105
8969
|
/** Awaited BEFORE a worker spawns — open a per-task checkpoint frame, audit, etc.
|
|
8106
8970
|
* (post-spawn would race the worker's first edits). */
|
|
8107
8971
|
onTaskStart;
|
|
@@ -8110,6 +8974,13 @@ var DuplexAgentOptions = class {
|
|
|
8110
8974
|
progressUpdates = false;
|
|
8111
8975
|
/** Min ms between progress re-voices per task. */
|
|
8112
8976
|
progressIntervalMs = 25e3;
|
|
8977
|
+
/** Spoken budget of ONE task delivery (chars, ~3 short sentences). Past it the rest is parked — cut at a sentence
|
|
8978
|
+
* boundary, `spokenOffer` spoken instead — so "go on" continues it once (the parked/resume path) and an aside or
|
|
8979
|
+
* steer handles it like any parked delivery. The worker is also asked for a short spoken summary + one offer (the
|
|
8980
|
+
* source); this is the backstop. Live 20260924-093945: ~20s and ~70s monologues whose closing choice went unspoken.
|
|
8981
|
+
* The full result always folds into the transcript. 0 = unbudgeted. */
|
|
8982
|
+
spokenBudgetChars = 0;
|
|
8983
|
+
spokenOffer = "There's more. Want me to go on?";
|
|
8113
8984
|
/** Relay worker questions (AskUserQuestion + permission asks via parkQuestion) through the VOICE:
|
|
8114
8985
|
* the question re-voices as '[task <id> asks] …', the user answers conversationally, and the
|
|
8115
8986
|
* voice model resolves it with the AnswerTask tool. Off → host.ask passthrough (text menus). */
|
|
@@ -8128,20 +8999,30 @@ var DuplexAgentOptions = class {
|
|
|
8128
8999
|
/** User-scope memory dir for global facts (type=user/feedback). Forwarded to Remember's routing. */
|
|
8129
9000
|
memoryUserDir;
|
|
8130
9001
|
};
|
|
9002
|
+
var SESSION_ID_RE = /^\d{8}-\d{6}(?:-[\w-]+)?$/;
|
|
8131
9003
|
var RESERVED_EVENT_MARKER = /\[task\b[^\]\n]*\b(?:completed|failed|progress|asks)\b/i;
|
|
8132
9004
|
var RESERVED_EVENT_OPENER = /\[\s*task\b/i;
|
|
8133
9005
|
var STAGE_DIRECTION_RE = /^\(\s*(?:(?:waiting|checking|searching|thinking|processing|loading|working|fetching|looking)\b[^)]*|[^)]*(?:\.\.\.|…)\s*)\)$/i;
|
|
8134
9006
|
function nowLine() {
|
|
8135
9007
|
return `Current date and time: ${(/* @__PURE__ */ new Date()).toLocaleString("en-US", { weekday: "long", year: "numeric", month: "long", day: "numeric", hour: "numeric", minute: "2-digit", timeZoneName: "short" })}`;
|
|
8136
9008
|
}
|
|
9009
|
+
var STOP_INTENT = /\b(stop|cancel|no|nope|nah|never\s?mind|nvm|forget it|drop it|don'?t|quiet|shut up|enough|hold on|wait|pause|hang on|actually)\b/;
|
|
8137
9010
|
function isTrivialBarge(text) {
|
|
8138
9011
|
const t = text.trim().toLowerCase().replace(/[.,!?…\s]+$/g, "").replace(/^[.,!?…\s]+/, "");
|
|
8139
9012
|
if (!t) return false;
|
|
8140
|
-
if (
|
|
9013
|
+
if (STOP_INTENT.test(t)) return false;
|
|
8141
9014
|
const core = t.replace(/^(?:oh|um+|uh+|er+|hmm+|ah+|well|so|okay|ok|yeah|yep)[\s,]+/, "").trim();
|
|
8142
9015
|
return /^(?:oh|um+|uh+|er+|hmm+|ah+|sorry|oops|whoops|my bad|pardon|excuse me|apologies|go on|go ahead|continue|keep going|carry on|as you were|please continue|you were saying|sorry go on|sorry continue|go on then|go on please)$/.test(core || t);
|
|
8143
9016
|
}
|
|
8144
|
-
|
|
9017
|
+
function isOfferAcceptance(text) {
|
|
9018
|
+
const t = text.trim().toLowerCase().replace(/[.,!?…]+/g, " ").replace(/\s+/g, " ").trim();
|
|
9019
|
+
if (!t || STOP_INTENT.test(t)) return false;
|
|
9020
|
+
const core = t.replace(/^(?:oh|um+|uh+|well|so|okay|ok|alright)\s+/, "");
|
|
9021
|
+
return /^(?:(?:yes|yeah|yep|yup|sure|ok|okay|alright)(?: please| sure| go on| go ahead| tell me more| do it| why not)?|please|please do|sure thing|go for it|do it|more|tell me more|what else|and then|sounds good|absolutely|definitely|of course|why not|i'?d like that|let'?s hear it|let'?s do it)$/.test(core || t);
|
|
9022
|
+
}
|
|
9023
|
+
var isQuestion = (s) => /\?["'”’)\]]*$/.test(s.trim());
|
|
9024
|
+
var OFFER_OVERRUN = 150;
|
|
9025
|
+
var VOICE_SYSTEM_PROMPT = 'You are a spoken voice assistant \u2014 the user HEARS everything you say. Use short sentences. One idea per sentence. No markdown, no bullet lists, no code blocks, no headings, no emoji. Never emit stage directions or parenthetical asides about your own process \u2014 nothing like "(waiting for the result...)" or "(checking)"; while work runs, either say it as plain speech or end your turn.\nThis holds even when asked to "print", "list", "show", or "make a table" \u2014 there is no screen for the spoken channel. Speak it as flowing prose ("Tuesday is half a meter, Wednesday a bit less\u2026"), or if they truly need it on screen, route it to Act to render. Never emit dashes or pipes into speech.\nKeep turns SHORT \u2014 one to three sentences, then stop. Never lecture, enumerate cases, or add caveats unprompted. Conversation is a fast exchange: give the one thing asked, and let the user pull more if they want it.\nYou have three cognitive tiers \u2014 like a human brain:\n\u2022 YOU (reflex) \u2014 instant, lightweight. Handle greetings, simple questions, status checks, QuickLook.\n\u2022 `Act` \u2014 your hands. A background worker with its own configured tools and access to the user\'s environment (files and shell{{WORKER_WEB}}). Use for reading, editing, searching, running tasks, building \u2014 any real work.\n{{THINK_SLOT}}\nWhen you are unsure whether you can do or access something, do NOT assume and do NOT claim a capability you have not confirmed. To check what you can do, QuickLook `capabilities` (instant \u2014 it lists your worker\'s real tools) and answer from that. Never promise an ability that is not in your capabilities; if it is not there, tell the user plainly you can\'t. To actually DO real work, call `Act`. When the user mentions their project, folder, files, or environment ("this project", "the current folder", "my code"), call `Act` IMMEDIATELY \u2014 do not ask for paths or details the worker can discover itself. Never pretend to have done the work or invent results \u2014 the worker\'s report is your only source.\nYou cannot mute the microphone or stop voice capture yourself \u2014 no tool does it. If the user asks you to stop listening or turn the voice off, never claim you did: tell them to say exactly "voice off" (handled by the app directly), or type /voice.\nYou are NOT a knowledge base. For any question whose answer needs SPECIFIC verifiable facts you do not already have in hand \u2014 how to build/configure/implement something, exact API, library, entitlement, command or option names, current events, or particular numbers, dates, or names \u2014 do NOT answer from your own memory: you will confidently make things up (a fake API, a wrong entitlement, an event that did not happen). Route it to `Act`, which can search and verify, and speak only what its report says. DELEGATION RULE \u2014 decide for yourself, the user never has to push: if you cannot answer confidently from the conversation plus trivial well-known knowledge, do NOT refuse and do NOT guess \u2014 dispatch `Act` immediately with a clear brief and say you are checking. Anything needing CURRENT data (weather, news, prices, dates, sky/astronomy, "right now"), real computation, or verification is an automatic dispatch \u2014 never a refusal. The user should never need to say "search the web" or "think harder" to make you act; needing fresh or verified information IS the trigger. Refuse only what your worker genuinely cannot do (check `capabilities`), and say why. Answer inline ONLY for general conversation, chit-chat, and trivia you are sure of, or facts you can see via QuickLook. When elaborating on a completed task ("tell me more", "the gist"), stay strictly within what that result actually said \u2014 if the user asks for something the result did not cover, that is NEW information: dispatch `Act`, do not improvise.\nALWAYS react before you work: the FIRST thing in your turn is a brief spoken acknowledgement of what you heard and what you are about to do ("got it \u2014 opening that now", "sure, let me pull it up", "okay, checking"). NEVER call a tool (Act, Think, QuickLook) silently \u2014 the user must hear you react before you go quiet to work. After dispatching Act or Think, that same one short sentence IS your turn \u2014 end it and do not wait for the result. Exactly ONE short line: never stack a second acknowledgement, and never narrate your own presence or status while waiting ("I\'m here", "let me see", "still checking" right after you already acked). One clean ack then silence reads as competent; repeated check-ins read as nervous.\nA completed task speaks its OWN result to the user (the worker voices what matters as it finishes) \u2014 you do NOT re-voice clean task results. A FAILED or INCOMPLETE task still arrives as a "[task t1 failed] \u2026" event for you to handle. The completed result stays in YOUR context \u2014 it is yours to draw on. When the user follows up ("tell me more", "what else", "and?"), answer FROM that result first: you already have the detail, so elaborate on what you have. Do NOT spawn a fresh worker to re-search or re-gather what you were just handed. Re-dispatch ONLY when genuinely new information is needed \u2014 e.g. the user wants the full contents of a SPECIFIC source, which is one WebFetch of that URL, not a brand-new search. "[task t1 progress] \u2026" events are interim status, NOT results \u2014 on a genuinely LONG wait you MAY give one brief half-sentence aside, but only occasionally; silence while working is normal and fine (the user knows you are on it). Do NOT narrate every step or re-announce yourself. Never present progress as a finished result.\nCRITICAL: while a task is still running you have NO answer yet \u2014 never state a specific result of any kind (a number, size, count, name, path, or value). The real answer exists ONLY once the task finishes (the worker speaks it, and `TaskStatus` then returns it); inventing one meanwhile (a made-up disk size, commit count, etc.) is a serious error. Until then, only acknowledge and wait.\nNever read raw file paths, diffs, or code aloud verbatim.\nDo NOT end every turn with the same canned offer ("want a rundown?", "want the steps?"). Offer once at most; if the user pushes back, repeats themselves, or sounds unsatisfied ("you know what I mean?", "think deeper", "are you sure?"), do NOT re-offer the same thing \u2014 change approach: dispatch `Act`/`Think` to actually dig in, or ask one concrete clarifying question. Repeating a non-answer is worse than silence.\n"[task t1 asks] \u2026" events are QUESTIONS from a background task \u2014 relay to the user in your own words, short, then end your turn. When the user answers, call `AnswerTask` with that id and their answer. NEVER answer on the user\'s behalf for permissions or risky operations; if their reply is ambiguous, confirm first.\nIf the user\'s message sounds INCOMPLETE \u2014 trailing off mid-sentence, a fragment that needs more context ("and then we", "but the problem is"), hesitation fillers ("uh", "um") \u2014 call `Hold` instead of answering. This keeps listening for the rest of their thought. Only respond with substance when you have a complete question or request.\nDispatch discipline: send ONE self-contained task per request \u2014 a single worker with the full brief beats several workers with fragments (each worker starts fresh and re-discovers context). NEVER dispatch a worker just to read files or gather information \u2014 workers explore and discover context themselves; pass on what you already know and let one worker do the whole job. Split into parallel tasks only when the user asks for genuinely independent things. When a task completes, report its result and stop \u2014 do NOT dispatch follow-up work (verification, polish, extras) the user did not ask for, unless the report itself signals failure or doubt.\nDo not fire a second Act/Think for work already in flight, and NEVER spawn a second task to re-count, cross-check, or verify a result a worker already gave you \u2014 trust its answer; a single question gets ONE task. Call `TaskStatus` at most ONCE per turn; if a task is still running, give one brief status aside and end the turn \u2014 never poll it again and again in a loop. "Still on it" is ONLY for a repeat check on a task you already acknowledged earlier \u2014 NEVER as the ack when you first dispatch work ("still" is wrong when nothing was running yet; use a fresh "on it"/"got it, checking" instead). Use `CancelTask` when the user asks to stop something.\nPRIORITY: when the user says goodbye or wants to end/finish/wrap up the session ("ok bye", "that\'s all", "let\'s finish", "let\'s end", "goodnight", "exit", "wrap up"), call `ExitSession` IMMEDIATELY \u2014 do not act, do not check status, just exit.\nFor TRIVIAL instant lookups only \u2014 current time, git branch, listing a folder, peeking at a small file, or checking your own `capabilities`/tools \u2014 use `QuickLook` (instant, no task). Whenever the user asks what you can do or whether you have some ability, QuickLook `capabilities` and answer from that \u2014 never guess. Anything requiring searching, reasoning, running commands, or editing goes through `Act`.\n{{MEMORY_SLOT}}\nUser messages may arrive via speech-to-text and can carry transcription artifacts \u2014 odd words, cut-offs, homophones ("for you" vs "folder"). Read for INTENT, not surface text. If a message seems garbled, surprising, or only half-parses, do NOT guess an action or improvise content from it \u2014 briefly confirm what they meant ("did you mean\u2026?") and wait. A one-line confirm beats a confident wrong answer or an invented response to a request you did not actually understand.';
|
|
8145
9026
|
var THINK_GUIDANCE = "\u2022 `Think` \u2014 your brain. A premium reasoning model, FAR more expensive than Act. Reserve it for open-ended architecture/design questions, or a problem Act already FAILED at. ALL implementation work \u2014 coding, refactoring, debugging, edge cases, tests \u2014 goes to Act; Act is highly capable. Never send the same work to both.";
|
|
8146
9027
|
var THINK_DISABLED_GUIDANCE = "(Think tier is not available \u2014 use Act for all escalations.)";
|
|
8147
9028
|
var VOICE_STYLE_CONVERSATIONAL = `Speak like a person in a live conversation, not an assistant reading a script. React first, then deliver: a quick impulsive beat ("oh nice", "hmm, hold on", "ah, got it") before the substance. Use contractions always. Vary sentence length \u2014 some very short. Light fillers and backchannels are fine ("mm-hm", "right", "let's see") but at most one per reply \u2014 never stack them. When you escalate to Act or Think, say it like a human would ("hang on, let me actually dig into that \u2014 gimme a minute") instead of announcing a task. When a result comes back, react to it like you just found out ("okay so \u2014 turns out\u2026"). Match the user's energy: a quick question gets a quick answer \u2014 a few words is a perfectly good turn. Prefer a short answer plus an offer ("want the details?") over covering everything. Never narrate your own mechanics (no "I will now act", no task ids out loud).`;
|
|
@@ -8156,14 +9037,22 @@ var DuplexAgent = class _DuplexAgent {
|
|
|
8156
9037
|
* (TaskRecord.createdTurn) so parking can tell a prior-topic task from the current turn's fresh one. */
|
|
8157
9038
|
turn = 0;
|
|
8158
9039
|
pendingEvents = [];
|
|
8159
|
-
/**
|
|
8160
|
-
*
|
|
9040
|
+
/** THE parked delivery: every piece the user has NOT heard, in speaking order — the unheard rest a barge cut out of
|
|
9041
|
+
* the voice queue (parkCutDelivery) followed by each segment a barge-parked task produced since (deliverSegment).
|
|
9042
|
+
* Nothing already voiced is ever added, so a resume never repeats what was heard. The next line's barge intent
|
|
9043
|
+
* decides: resume → spoken verbatim, aside → handed to the reflex to carry on after its answer (asideNote) — both
|
|
9044
|
+
* send the barge-parked tasks live again; steer/stop → cleared, and those tasks
|
|
9045
|
+
* are moved on from (the full result stays in the transcript, recoverable by asking). `task` = the owner, so a
|
|
9046
|
+
* task whose outcome goes to an integration turn takes its pieces back (dropParked). */
|
|
8161
9047
|
parkedRedeliver = [];
|
|
8162
|
-
/**
|
|
8163
|
-
|
|
8164
|
-
|
|
8165
|
-
|
|
8166
|
-
|
|
9048
|
+
/** parkedRedeliver length when the last barge parked (parkInFlightDeliveries): the cut it hands over later
|
|
9049
|
+
* (parkCutDelivery) was voiced BEFORE anything parked since, so it is inserted here. */
|
|
9050
|
+
bargeMark = 0;
|
|
9051
|
+
/** The last barge cut a REFLEX reply (parkCutDelivery): a "go on" is then the reflex's to answer — it runs a normal
|
|
9052
|
+
* turn (the host's interruption note continues the reply), and only then does the parked delivery resume. */
|
|
9053
|
+
reflexCut = false;
|
|
9054
|
+
/** Recent live task segments → their task id: attributes a cut delivery (parkCutDelivery) to the task that spoke it. */
|
|
9055
|
+
voicedBy = /* @__PURE__ */ new Map();
|
|
8167
9056
|
/** Out-of-band follow-up attribution for the events coalescing into the next flush turn: TRUE iff ≥1 of
|
|
8168
9057
|
* the tasks being integrated was NON-CLEAN (early-stop/failure). Carried out-of-band on the enqueue call
|
|
8169
9058
|
* by the caller that KNOWS the outcome — a plain boolean the MODEL CANNOT PERTURB. It is NOT scanned from
|
|
@@ -8196,6 +9085,24 @@ var DuplexAgent = class _DuplexAgent {
|
|
|
8196
9085
|
// chars of reflexBuf already forwarded to the host/TTS
|
|
8197
9086
|
fabricationCut = false;
|
|
8198
9087
|
// reflex emitted a reserved [task …] marker → suppress its tail
|
|
9088
|
+
leakCuts = [];
|
|
9089
|
+
// heads of analysis-register leaks cut this turn — scrubbed from the transcript at turn end
|
|
9090
|
+
leakStep = false;
|
|
9091
|
+
// the CURRENT step's tail is muted by a leak cut (the next step speaks again, unlike a fabricated marker)
|
|
9092
|
+
leakHit = false;
|
|
9093
|
+
// this turn (or its repair) had a leak cut — the dead-air fallback must not blame the user
|
|
9094
|
+
stepMarks = [];
|
|
9095
|
+
// reflexBuf offsets where a step ended (its text is complete there)
|
|
9096
|
+
leakVerdicts = /* @__PURE__ */ new Map();
|
|
9097
|
+
// judged suspect sentences this turn (true = leak)
|
|
9098
|
+
judging;
|
|
9099
|
+
// the in-flight leak judge (one at a time — the gate is sequential)
|
|
9100
|
+
turnGen = 0;
|
|
9101
|
+
// bumped whenever the gated stream is replaced — a late verdict for an old one is dropped
|
|
9102
|
+
turnSpoken = "";
|
|
9103
|
+
// reflex text actually forwarded to the host this turn
|
|
9104
|
+
turnBase = 0;
|
|
9105
|
+
// transcript length at turn start (scope for the leak scrub)
|
|
8199
9106
|
/** TRUE for the duration of a re-voice turn that is integrating ≥1 NON-CLEAN task (turn-eligibility,
|
|
8200
9107
|
* carried out-of-band — NOT derived from any worker/brief string). ANY Act/Think dispatched in such a
|
|
8201
9108
|
* turn is stamped followUp:true. This GUARANTEES the dangerous direction is impossible: a genuine
|
|
@@ -8244,7 +9151,8 @@ var DuplexAgent = class _DuplexAgent {
|
|
|
8244
9151
|
...new Set(workerToolNames.filter((n) => n.startsWith("mcp__")).map((n) => n.slice(5).split("__")[0]))
|
|
8245
9152
|
];
|
|
8246
9153
|
const workerMcp = mcpNames.length ? `, and it can use these MCP servers: ${[...new Set(mcpNames)].join(", ")}` + (mcpNames.some((n) => /browser/i.test(n)) ? ' \u2014 including driving a REAL browser (open tabs, navigate, click, screenshot), so answer "yes" if asked whether you can control/drive a browser and route an actual browse to Act' : "") : "";
|
|
8247
|
-
const prompt = VOICE_SYSTEM_PROMPT.replace("{{MEMORY_SLOT}}", memSlot).replace("{{THINK_SLOT}}", thinkSlot).replace("{{WORKER_WEB}}", workerWeb + workerMcp) + (o.
|
|
9154
|
+
const prompt = VOICE_SYSTEM_PROMPT.replace("{{MEMORY_SLOT}}", memSlot).replace("{{THINK_SLOT}}", thinkSlot).replace("{{WORKER_WEB}}", workerWeb + workerMcp) + (o.quickLook?.sessions ? `
|
|
9155
|
+
Past conversations with this user are saved. When they refer to an EARLIER conversation or session ("last time", "the previous session", "you already did it before", "earlier today"), call QuickLook what:"sessions" (then what:"sessions" with path:"<id>" to read one) BEFORE answering \u2014 never say it does not exist without checking. Report only what the recap says: if it does not say why something happened, say you don't know \u2014 never guess a motive.` : "") + (o.voiceStyle === "conversational" ? "\n" + VOICE_STYLE_CONVERSATIONAL : "") + (o.emotionTags ? "\n" + EMOTION_TAGS_GUIDANCE : "") + `
|
|
8248
9156
|
${nowLine()}. Anchor every relative or time-sensitive reference \u2014 "today", "now", "current", "recent", "latest", "this year" \u2014 to THIS moment. When the user asks about something recent (news, sports, events), they mean near this date, not a well-known past instance; brief your worker with the current year so it searches for what is happening NOW, not a famous older event.`;
|
|
8249
9157
|
const tools = [
|
|
8250
9158
|
...o.reflexOptions?.tools ?? [],
|
|
@@ -8265,32 +9173,26 @@ ${nowLine()}. Anchor every relative or time-sensitive reference \u2014 "today",
|
|
|
8265
9173
|
if (this.heldThisTurn) return;
|
|
8266
9174
|
if (this.fabricationCut) return;
|
|
8267
9175
|
if (this.turnDispatched && this.spokeBeforeDispatch) return;
|
|
8268
|
-
|
|
8269
|
-
this.reflexBuf += msg;
|
|
8270
|
-
this.scrubStageDirections();
|
|
9176
|
+
this.reflexBuf += ev.message;
|
|
8271
9177
|
const m = this.reflexBuf.match(RESERVED_EVENT_MARKER) ?? this.reflexBuf.match(RESERVED_EVENT_OPENER);
|
|
8272
9178
|
if (m) {
|
|
8273
9179
|
this.fabricationCut = true;
|
|
8274
|
-
|
|
8275
|
-
|
|
8276
|
-
|
|
8277
|
-
if (safe.trim()) this.spokeThisTurn = true;
|
|
8278
|
-
this.emitHost({ ...ev, message: safe });
|
|
9180
|
+
log21.warn(`reflex fabricated a [task \u2026] event in its spoken stream \u2014 cutting it (kept ${m.index} chars)`);
|
|
9181
|
+
this.reflexBuf = this.reflexBuf.slice(0, Math.max(m.index, this.reflexForwarded));
|
|
9182
|
+
this.pumpReflex(true);
|
|
8279
9183
|
return;
|
|
8280
9184
|
}
|
|
8281
|
-
|
|
8282
|
-
const partial = held > 0 && /\[\s*t?a?s?k?$/i.test(this.reflexBuf.slice(-Math.min(held, 6)));
|
|
8283
|
-
let upto = partial ? this.reflexBuf.length - this.reflexBuf.slice(-6).match(/\[\s*t?a?s?k?$/i)[0].length : this.reflexBuf.length;
|
|
8284
|
-
const paren = this.reflexBuf.lastIndexOf("(");
|
|
8285
|
-
if (paren >= this.reflexForwarded && !this.reflexBuf.includes(")", paren) && this.reflexBuf.length - paren <= 80)
|
|
8286
|
-
upto = Math.min(upto, paren);
|
|
8287
|
-
const out = this.reflexBuf.slice(this.reflexForwarded, upto);
|
|
8288
|
-
this.reflexForwarded = upto;
|
|
8289
|
-
if (!out) return;
|
|
8290
|
-
if (out.trim()) this.spokeThisTurn = true;
|
|
8291
|
-
this.emitHost({ ...ev, message: out });
|
|
9185
|
+
this.pumpReflex(false);
|
|
8292
9186
|
return;
|
|
8293
9187
|
}
|
|
9188
|
+
if (ev?.kind === "turn_start" || ev?.kind === "tool_use") {
|
|
9189
|
+
this.stepMarks.push(this.reflexBuf.length);
|
|
9190
|
+
if (this.leakStep) {
|
|
9191
|
+
this.leakStep = false;
|
|
9192
|
+
this.reflexForwarded = this.reflexBuf.length;
|
|
9193
|
+
}
|
|
9194
|
+
this.pumpReflex(false);
|
|
9195
|
+
}
|
|
8294
9196
|
host.notify?.(ev);
|
|
8295
9197
|
}
|
|
8296
9198
|
};
|
|
@@ -8324,16 +9226,163 @@ ${nowLine()}. Anchor every relative or time-sensitive reference \u2014 "today",
|
|
|
8324
9226
|
this.voice.options.tools.push(...mem.tools);
|
|
8325
9227
|
if (mem.index) this.voice.options.systemPrompt += "\n\n" + mem.index;
|
|
8326
9228
|
}
|
|
8327
|
-
/**
|
|
8328
|
-
*
|
|
8329
|
-
|
|
8330
|
-
|
|
8331
|
-
|
|
8332
|
-
|
|
8333
|
-
|
|
8334
|
-
|
|
8335
|
-
|
|
8336
|
-
|
|
9229
|
+
/** Forward what the reflex's spoken-stream gates allow (text_delta → host/TTS; forwarded audio can't be unsent):
|
|
9230
|
+
* - hold a trailing fragment that could be a `[task` opener split across deltas ("[ta" + "sk"), and an UNCLOSED
|
|
9231
|
+
* "(" (a stage direction is judged — and dropped — only once its ")" arrives; bounded at 80 chars);
|
|
9232
|
+
* - the leak gate: whole sentences only, each screened by isLeakSuspect the moment it completes — a strict match
|
|
9233
|
+
* is cut, any other suspect waits for the decider's verdict (judgeLeak) while the rest queues behind it.
|
|
9234
|
+
* `final` = the stream is done: release the holds and judge whatever is left as a sentence. Text before a step
|
|
9235
|
+
* boundary (stepMarks) is complete — no holds, its tail is a sentence. Idempotent; re-run as verdicts land. */
|
|
9236
|
+
pumpReflex(final) {
|
|
9237
|
+
for (; ; ) {
|
|
9238
|
+
if (this.leakStep) return;
|
|
9239
|
+
this.scrubStageDirections();
|
|
9240
|
+
const from = this.reflexForwarded;
|
|
9241
|
+
const buf = this.reflexBuf;
|
|
9242
|
+
const lastMark = Math.max(from, ...this.stepMarks);
|
|
9243
|
+
let upto = buf.length;
|
|
9244
|
+
if (!final && upto > lastMark) {
|
|
9245
|
+
const tail = buf.slice(Math.max(lastMark, upto - 6)).match(/\[\s*t?a?s?k?$/i);
|
|
9246
|
+
if (tail) upto -= tail[0].length;
|
|
9247
|
+
const paren = buf.lastIndexOf("(");
|
|
9248
|
+
if (paren >= lastMark && !buf.includes(")", paren) && buf.length - paren <= 80) upto = Math.min(upto, paren);
|
|
9249
|
+
}
|
|
9250
|
+
if (upto <= from) return;
|
|
9251
|
+
const region = buf.slice(from, upto);
|
|
9252
|
+
const ends = [...region.matchAll(SENTENCE_END)].map((m) => m.index + m[0].length);
|
|
9253
|
+
for (const k of this.stepMarks) if (k > from && k <= upto) ends.push(k - from);
|
|
9254
|
+
if (final || !ends.length && region.length >= 160) ends.push(region.length);
|
|
9255
|
+
ends.sort((a, b) => a - b);
|
|
9256
|
+
let ok = 0, leak;
|
|
9257
|
+
for (const e of ends) {
|
|
9258
|
+
if (e <= ok) continue;
|
|
9259
|
+
const v = this.leakVerdict(region.slice(ok, e));
|
|
9260
|
+
if (v === void 0) break;
|
|
9261
|
+
if (v) {
|
|
9262
|
+
leak = region.slice(ok, e);
|
|
9263
|
+
break;
|
|
9264
|
+
}
|
|
9265
|
+
ok = e;
|
|
9266
|
+
}
|
|
9267
|
+
this.forwardReflex(from + ok);
|
|
9268
|
+
if (leak === void 0) return;
|
|
9269
|
+
const at = from + ok;
|
|
9270
|
+
this.leakHit = true;
|
|
9271
|
+
this.leakCuts.push(leak.trim().slice(0, 40));
|
|
9272
|
+
log21.warn(`reflex spoke its own analysis ("${leak.trim().slice(0, 40)}\u2026") \u2014 cut before TTS`);
|
|
9273
|
+
this.notify("diag", "analysis_leak", { text: leak.trim().slice(0, 80), at: this.turnSpoken.trim() ? "mid_reply" : "before_speech" });
|
|
9274
|
+
const next = this.stepMarks.find((k) => k > at);
|
|
9275
|
+
if (next === void 0) {
|
|
9276
|
+
this.reflexForwarded = buf.length;
|
|
9277
|
+
this.leakStep = true;
|
|
9278
|
+
return;
|
|
9279
|
+
}
|
|
9280
|
+
this.reflexForwarded = next;
|
|
9281
|
+
}
|
|
9282
|
+
}
|
|
9283
|
+
forwardReflex(to) {
|
|
9284
|
+
const out = this.reflexBuf.slice(this.reflexForwarded, to);
|
|
9285
|
+
this.reflexForwarded = Math.max(this.reflexForwarded, to);
|
|
9286
|
+
if (!out) return;
|
|
9287
|
+
if (out.trim()) this.spokeThisTurn = true;
|
|
9288
|
+
this.turnSpoken += out;
|
|
9289
|
+
this.emitHost({ kind: "text_delta", message: out });
|
|
9290
|
+
}
|
|
9291
|
+
/** Leak verdict for one complete sentence: false = speak, true = cut, undefined = the judge is still out.
|
|
9292
|
+
* Non-suspects pass free; strict shapes are cut without a call; other suspects go to the decider (if any) —
|
|
9293
|
+
* one call at a time, cached per turn. A failed/slow judge fails OPEN (speech is never muted on a guess). */
|
|
9294
|
+
leakVerdict(sentence) {
|
|
9295
|
+
const s = sentence.trim();
|
|
9296
|
+
if (!s || !isLeakSuspect(s)) return false;
|
|
9297
|
+
if (isStrictLeak(s)) return true;
|
|
9298
|
+
const decider = this.options.decider;
|
|
9299
|
+
if (!decider) return false;
|
|
9300
|
+
const known = this.leakVerdicts.get(s);
|
|
9301
|
+
if (known !== void 0 || this.judging) return known;
|
|
9302
|
+
const gen = this.turnGen, t0 = performance.now();
|
|
9303
|
+
const userSaid = [...this.voice.transcript].reverse().map((m) => m.role === "user" ? contentText(m.content) : "").find((t) => t && !t.startsWith("[reminder]")) ?? "";
|
|
9304
|
+
let timer;
|
|
9305
|
+
const timeout = new Promise((_, rej) => {
|
|
9306
|
+
timer = setTimeout(() => rej(new Error(`no verdict in ${_DuplexAgent.LEAK_JUDGE_TIMEOUT_MS}ms`)), _DuplexAgent.LEAK_JUDGE_TIMEOUT_MS);
|
|
9307
|
+
});
|
|
9308
|
+
this.judging = Promise.race([judgeLeak(decider, s, userSaid), timeout]).then((p) => {
|
|
9309
|
+
this.notify("diag", "leak_judge", { text: s.slice(0, 80), p: Math.round(p * 100) / 100, ms: Math.round(performance.now() - t0) });
|
|
9310
|
+
return p >= LEAK_JUDGE_THRESHOLD;
|
|
9311
|
+
}, (e) => {
|
|
9312
|
+
log21.warn(`leak judge failed \u2014 speaking the sentence: ${e.message}`);
|
|
9313
|
+
return false;
|
|
9314
|
+
}).then((leak) => {
|
|
9315
|
+
clearTimeout(timer);
|
|
9316
|
+
if (gen !== this.turnGen) return;
|
|
9317
|
+
this.judging = void 0;
|
|
9318
|
+
this.leakVerdicts.set(s, leak);
|
|
9319
|
+
this.pumpReflex(false);
|
|
9320
|
+
});
|
|
9321
|
+
return void 0;
|
|
9322
|
+
}
|
|
9323
|
+
static LEAK_JUDGE_TIMEOUT_MS = 1500;
|
|
9324
|
+
/** The stream is done: settle every pending verdict and release everything the gates still hold. */
|
|
9325
|
+
async drainReflex() {
|
|
9326
|
+
for (; ; ) {
|
|
9327
|
+
this.pumpReflex(true);
|
|
9328
|
+
if (!this.judging) return;
|
|
9329
|
+
await this.judging;
|
|
9330
|
+
}
|
|
9331
|
+
}
|
|
9332
|
+
/** Drop any in-flight judge of the current stream (its verdict must not pump a stream that moved on). */
|
|
9333
|
+
dropJudge() {
|
|
9334
|
+
this.turnGen++;
|
|
9335
|
+
this.judging = void 0;
|
|
9336
|
+
}
|
|
9337
|
+
/** Remove a cut analysis leak from this turn's assistant message(s) — left in, it primes the next turn to
|
|
9338
|
+
* leak again and reads as something we said. Keeps any legit prefix; drops a message left empty. */
|
|
9339
|
+
scrubLeakFromTranscript() {
|
|
9340
|
+
const keys = this.leakCuts.splice(0);
|
|
9341
|
+
const t = this.voice.transcript;
|
|
9342
|
+
for (const key of keys) {
|
|
9343
|
+
let i = t.length - 1;
|
|
9344
|
+
while (i >= this.turnBase && !(t[i].role === "assistant" && typeof t[i].content === "string" && t[i].content.includes(key))) i--;
|
|
9345
|
+
if (i < this.turnBase) {
|
|
9346
|
+
log21.warn("analysis leak cut, but its text was not found in the transcript to scrub");
|
|
9347
|
+
continue;
|
|
9348
|
+
}
|
|
9349
|
+
const m = t[i];
|
|
9350
|
+
m.content = m.content.slice(0, m.content.indexOf(key)).trimEnd();
|
|
9351
|
+
if (!m.content && !m.tool_calls?.length) t.splice(i, 1);
|
|
9352
|
+
}
|
|
9353
|
+
}
|
|
9354
|
+
/** A Hold turn is silent BY DESIGN: every reflex text after the Hold is muted (voiceHost drops it), so none of it may
|
|
9355
|
+
* persist either — the transcript is what the agent SAID. Live 20260923-125143 msg 10: the model's reasoning ("We
|
|
9356
|
+
* need to respond according to guidelines… So we should hold") was saved as an assistant reply and fed every later
|
|
9357
|
+
* turn. Strips the text of assistant messages after the Hold call (tool calls stay — they happened); a message left
|
|
9358
|
+
* empty is removed. */
|
|
9359
|
+
scrubHeldText() {
|
|
9360
|
+
if (!this.heldThisTurn) return;
|
|
9361
|
+
const t = this.voice.transcript;
|
|
9362
|
+
let i = this.turnBase;
|
|
9363
|
+
while (i < t.length && !t[i].tool_calls?.some((c) => c.function.name === "Hold")) i++;
|
|
9364
|
+
for (let j = t.length - 1; j > i; j--) {
|
|
9365
|
+
const m = t[j];
|
|
9366
|
+
if (m.role !== "assistant" || !contentText(m.content).trim()) continue;
|
|
9367
|
+
if (m.tool_calls?.length) m.content = "";
|
|
9368
|
+
else t.splice(j, 1);
|
|
9369
|
+
}
|
|
9370
|
+
}
|
|
9371
|
+
/** Shared turn epilogue (user turn, confirmed speculation, re-voice): release held text, scrub a cut leak,
|
|
9372
|
+
* then repair dead air — unless the turn was ABORTED: the user took the floor (barge-in / Esc), which is
|
|
9373
|
+
* not dead air, and a nudge would run on the same aborted signal (live: the "[reminder]" landed in the
|
|
9374
|
+
* transcript as a user turn with no reply, session 20260923-101910 msg 27). */
|
|
9375
|
+
async endTurn(res, fallback) {
|
|
9376
|
+
if (res?.finishReason === "aborted") {
|
|
9377
|
+
this.dropJudge();
|
|
9378
|
+
this.scrubLeakFromTranscript();
|
|
9379
|
+
this.scrubHeldText();
|
|
9380
|
+
return;
|
|
9381
|
+
}
|
|
9382
|
+
await this.drainReflex();
|
|
9383
|
+
this.scrubLeakFromTranscript();
|
|
9384
|
+
this.scrubHeldText();
|
|
9385
|
+
if (this.silentTurn) await this.ackIfSilent(fallback);
|
|
8337
9386
|
}
|
|
8338
9387
|
/** Remove complete stage-direction parentheticals from the UNFORWARDED reflex text (STAGE_DIRECTION_RE).
|
|
8339
9388
|
* Only the unforwarded region is touched — already-spoken audio can't be unsent, and splicing before
|
|
@@ -8355,6 +9404,14 @@ ${nowLine()}. Anchor every relative or time-sensitive reference \u2014 "today",
|
|
|
8355
9404
|
this.reflexBuf = "";
|
|
8356
9405
|
this.reflexForwarded = 0;
|
|
8357
9406
|
this.fabricationCut = false;
|
|
9407
|
+
this.leakCuts = [];
|
|
9408
|
+
this.leakStep = this.leakHit = false;
|
|
9409
|
+
this.stepMarks = [];
|
|
9410
|
+
this.leakVerdicts.clear();
|
|
9411
|
+
this.turnSpoken = "";
|
|
9412
|
+
this.offerOpen = false;
|
|
9413
|
+
this.dropJudge();
|
|
9414
|
+
this.turnBase = this.voice.transcript.length;
|
|
8358
9415
|
this.turnFollowUp = false;
|
|
8359
9416
|
this.voice.options.toolChoice = void 0;
|
|
8360
9417
|
}
|
|
@@ -8366,7 +9423,7 @@ ${nowLine()}. Anchor every relative or time-sensitive reference \u2014 "today",
|
|
|
8366
9423
|
return {
|
|
8367
9424
|
preToolUse: (call) => {
|
|
8368
9425
|
if (this.spec?.state === "pending" && !_DuplexAgent.SPEC_SAFE_TOOLS.has(call.name)) {
|
|
8369
|
-
|
|
9426
|
+
log21.verbose(`speculation aborted: reflex called ${call.name} on unconfirmed input`);
|
|
8370
9427
|
this.abortSpeculation();
|
|
8371
9428
|
return { block: true, reason: "Speculative turn aborted." };
|
|
8372
9429
|
}
|
|
@@ -8400,16 +9457,27 @@ ${nowLine()}. Anchor every relative or time-sensitive reference \u2014 "today",
|
|
|
8400
9457
|
* Wording adapts to whether work was dispatched (an ack) or the inline reply was simply lost. */
|
|
8401
9458
|
async ackIfSilent(fallback) {
|
|
8402
9459
|
const dispatched = this.turnDispatched;
|
|
9460
|
+
const reminder = fallback ? "[reminder] You said nothing to the user this turn. Tell them, in ONE short spoken sentence, what just happened \u2014 no tools." : dispatched ? "[reminder] You dispatched a task but said nothing to the user. Say ONE short spoken acknowledgement now \u2014 no tools." : "[reminder] You said nothing to the user this turn. Give your ONE short spoken reply now \u2014 no tools.";
|
|
9461
|
+
const base = this.voice.transcript.length;
|
|
9462
|
+
this.fabricationCut = this.leakStep = false;
|
|
9463
|
+
this.reflexBuf = "";
|
|
9464
|
+
this.reflexForwarded = 0;
|
|
9465
|
+
this.stepMarks = [];
|
|
9466
|
+
this.dropJudge();
|
|
8403
9467
|
this.nudging = true;
|
|
8404
9468
|
try {
|
|
8405
|
-
await this.voice.send(
|
|
9469
|
+
await this.voice.send(reminder);
|
|
8406
9470
|
} catch (e) {
|
|
8407
|
-
|
|
9471
|
+
log21.warn(`ack nudge failed: ${e instanceof Error ? e.message : e}`);
|
|
8408
9472
|
} finally {
|
|
8409
9473
|
this.nudging = false;
|
|
9474
|
+
const i = this.voice.transcript.findIndex((m, k) => k >= base && m.role === "user" && m.content === reminder);
|
|
9475
|
+
if (i >= 0) this.voice.transcript.splice(i, 1);
|
|
9476
|
+
await this.drainReflex();
|
|
9477
|
+
this.scrubLeakFromTranscript();
|
|
8410
9478
|
}
|
|
8411
9479
|
if (!this.spokeThisTurn) {
|
|
8412
|
-
const pool = fallback ? [fallback] : dispatched ? _DuplexAgent.FALLBACK_ACKS : _DuplexAgent.FALLBACK_RETRY;
|
|
9480
|
+
const pool = fallback ? [fallback] : dispatched ? _DuplexAgent.FALLBACK_ACKS : this.leakHit ? _DuplexAgent.FALLBACK_OWN : _DuplexAgent.FALLBACK_RETRY;
|
|
8413
9481
|
const fresh = pool.filter((p) => p !== this.lastFallback);
|
|
8414
9482
|
const line = (fresh.length ? fresh : pool)[Math.floor(Math.random() * (fresh.length || pool.length))];
|
|
8415
9483
|
this.lastFallback = line;
|
|
@@ -8420,26 +9488,134 @@ ${nowLine()}. Anchor every relative or time-sensitive reference \u2014 "today",
|
|
|
8420
9488
|
* hosts/tests key on it. */
|
|
8421
9489
|
static FALLBACK_ACKS = ["Okay, on it.", "On it.", "Alright, working on it."];
|
|
8422
9490
|
static FALLBACK_RETRY = ["Sorry, could you say that again?", "Hm, I missed that \u2014 could you say that again?"];
|
|
9491
|
+
static FALLBACK_OWN = ["Sorry, I lost my train of thought there \u2014 give me that one more time?", "Hm, I got tangled up \u2014 ask me that once more?"];
|
|
8423
9492
|
lastFallback = "";
|
|
8424
9493
|
/** One user turn: the voice agent streams the reply (and may Act/Think). Serialized with re-voice turns.
|
|
8425
9494
|
* If a speculative reflex call is pending and this content CONFIRMS it (the final matches the
|
|
8426
9495
|
* speculated partial), the in-flight call is adopted as THE turn: its buffered output flushes to the
|
|
8427
9496
|
* host NOW (this is the latency win) and streaming continues live. Any other content aborts the
|
|
8428
|
-
* speculation first (rolled back silently) and runs a normal turn behind it.
|
|
8429
|
-
|
|
8430
|
-
|
|
8431
|
-
|
|
8432
|
-
|
|
8433
|
-
|
|
8434
|
-
|
|
8435
|
-
|
|
9497
|
+
* speculation first (rolled back silently) and runs a normal turn behind it.
|
|
9498
|
+
* `opts.id` names the turn; `opts.replaces` names an earlier one this content REPLACES — the voice engine's
|
|
9499
|
+
* fuller re-finalization of an utterance whose reply had not started ("…previous" → "…previous
|
|
9500
|
+
* conversation."). The replaced turn is skipped if it has not run yet, else aborted and rolled back out of
|
|
9501
|
+
* the transcript (kept only if it already dispatched work) — one reply, to the full text. */
|
|
9502
|
+
send(content, opts) {
|
|
9503
|
+
if (opts?.replaces) this.replaceTurn(opts.replaces);
|
|
9504
|
+
const cur = this.voice.options.signal;
|
|
9505
|
+
const ext = cur && cur === this.turnSignal?.own ? this.turnSignal.ext : cur;
|
|
9506
|
+
const rec = opts?.id ? this.trackTurn(opts.id) : void 0;
|
|
9507
|
+
if (rec?.superseded) {
|
|
9508
|
+
this.notify("diag", "turn_replaced", { id: opts.id, ran: false });
|
|
9509
|
+
return Promise.resolve(_DuplexAgent.replacedResult(this.voice.transcript));
|
|
9510
|
+
}
|
|
9511
|
+
const reflexCut = this.reflexCut;
|
|
9512
|
+
this.reflexCut = false;
|
|
9513
|
+
if (typeof content === "string" && this.hasParkedDelivery()) {
|
|
9514
|
+
const fallback = () => isTrivialBarge(content) ? "resume" : "steer";
|
|
9515
|
+
const accepted = this.offerOpen && this.parkedRedeliver.length > 0 && this.parkedRedeliver.every((p) => p.task === this.offer.task) && (isOfferAcceptance(content) || isTrivialBarge(content));
|
|
9516
|
+
const judge = accepted ? void 0 : this.options.bargeIntent;
|
|
9517
|
+
return (accepted ? Promise.resolve("resume") : judge ? judge(content).catch((e) => {
|
|
9518
|
+
log21.warn(`bargeIntent judge failed \u2014 using the allowlist: ${e.message}`);
|
|
9519
|
+
return fallback();
|
|
9520
|
+
}) : Promise.resolve(fallback())).then((intent) => {
|
|
9521
|
+
this.notify("diag", "barge_intent", { intent, text: content.slice(0, 80) });
|
|
9522
|
+
if (intent === "resume" && !reflexCut) return this.resumeParked();
|
|
9523
|
+
if (intent === "aside" || intent === "resume") {
|
|
9524
|
+
const told = intent === "aside" ? [...this.parkedRedeliver] : [];
|
|
9525
|
+
return this.sendTurn(intent === "aside" ? content + this.asideNote(told) : content, true, rec, ext).then(async (r) => {
|
|
9526
|
+
if (r.finishReason === "aborted" || this.heldThisTurn) return r;
|
|
9527
|
+
const missed = unsaidClauses(told.map((p) => p.text), this.turnSpoken, this.options.decider);
|
|
9528
|
+
const turn = this.turn;
|
|
9529
|
+
this.parkedRedeliver = this.parkedRedeliver.filter((p) => !told.includes(p));
|
|
9530
|
+
await this.resumeParked();
|
|
9531
|
+
void missed.then((m) => {
|
|
9532
|
+
const dropped = m.filter((c) => !isQuestion(c)).join(" ").replace(/[,;]$/, ".");
|
|
9533
|
+
if (!dropped || this.turn !== turn) return;
|
|
9534
|
+
this.parkedRedeliver.unshift({ text: dropped, task: told.at(-1)?.task });
|
|
9535
|
+
this.notify("diag", "aside_dropped", { text: dropped.slice(0, 80) });
|
|
9536
|
+
});
|
|
9537
|
+
return r;
|
|
9538
|
+
});
|
|
9539
|
+
}
|
|
9540
|
+
return this.sendTurn(content, false, rec, ext);
|
|
8436
9541
|
});
|
|
8437
9542
|
}
|
|
9543
|
+
return this.sendTurn(content, false, rec, ext);
|
|
9544
|
+
}
|
|
9545
|
+
/** Host-named user turns (send opts.id), so a later send can replace one. Bounded; a replace that arrives
|
|
9546
|
+
* BEFORE its target (the host's dispatch hops can reorder) leaves a pre-superseded record behind. */
|
|
9547
|
+
userTurns = /* @__PURE__ */ new Map();
|
|
9548
|
+
turnSignal;
|
|
9549
|
+
// the combined signal a named turn installed while it runs
|
|
9550
|
+
trackTurn(id) {
|
|
9551
|
+
let rec = this.userTurns.get(id);
|
|
9552
|
+
if (!rec) {
|
|
9553
|
+
rec = { ctl: new AbortController(), done: false, superseded: false };
|
|
9554
|
+
this.userTurns.set(id, rec);
|
|
9555
|
+
if (this.userTurns.size > 32) this.userTurns.delete(this.userTurns.keys().next().value);
|
|
9556
|
+
}
|
|
9557
|
+
return rec;
|
|
9558
|
+
}
|
|
9559
|
+
replaceTurn(id) {
|
|
9560
|
+
const rec = this.trackTurn(id);
|
|
9561
|
+
if (rec.done) {
|
|
9562
|
+
const drop = rec.dropped;
|
|
9563
|
+
rec.dropped = void 0;
|
|
9564
|
+
if (drop?.length) void this.enqueue(async () => {
|
|
9565
|
+
const t = this.voice.transcript;
|
|
9566
|
+
for (const m of drop) {
|
|
9567
|
+
const i = t.indexOf(m);
|
|
9568
|
+
if (i >= 0) t.splice(i, 1);
|
|
9569
|
+
}
|
|
9570
|
+
this.notify("diag", "turn_replaced", { ran: true, rolledBack: true, late: true });
|
|
9571
|
+
});
|
|
9572
|
+
return;
|
|
9573
|
+
}
|
|
9574
|
+
if (rec.superseded) return;
|
|
9575
|
+
rec.superseded = true;
|
|
9576
|
+
rec.ctl.abort();
|
|
9577
|
+
}
|
|
9578
|
+
static replacedResult(messages) {
|
|
9579
|
+
return { text: "", steps: 0, finishReason: "aborted", messages };
|
|
9580
|
+
}
|
|
9581
|
+
/** A turn replaced mid-run: mute whatever it still holds, and erase it from the transcript (the replacing turn
|
|
9582
|
+
* carries the user's full words) — unless it already dispatched work or SPOKE: the task is real, and the user
|
|
9583
|
+
* heard those words, so both stay visible to the replacing turn. An aborted stream never reaches the transcript,
|
|
9584
|
+
* so what was spoken is recorded as the replaced turn's reply. */
|
|
9585
|
+
dropReplacedTurn() {
|
|
9586
|
+
this.fabricationCut = true;
|
|
9587
|
+
this.reflexBuf = this.reflexBuf.slice(0, this.reflexForwarded);
|
|
9588
|
+
this.dropJudge();
|
|
9589
|
+
const spoke = this.turnSpoken.trim();
|
|
9590
|
+
const rolledBack = !this.turnDispatched && !spoke;
|
|
9591
|
+
if (rolledBack) this.voice.transcript.length = this.turnBase;
|
|
9592
|
+
else if (spoke) {
|
|
9593
|
+
const t = this.voice.transcript;
|
|
9594
|
+
const have = t.slice(this.turnBase).filter((m) => m.role === "assistant").map((m) => contentText(m.content)).join(" ");
|
|
9595
|
+
if (!have.includes(spoke)) t.push({ role: "assistant", content: spoke });
|
|
9596
|
+
}
|
|
9597
|
+
this.notify("diag", "turn_replaced", { ran: true, rolledBack, spoke: !!spoke });
|
|
9598
|
+
return _DuplexAgent.replacedResult(this.voice.transcript);
|
|
9599
|
+
}
|
|
9600
|
+
resumeParked() {
|
|
9601
|
+
if (this.spec) this.abortSpeculation();
|
|
9602
|
+
return this.enqueue(async () => {
|
|
9603
|
+
this.resetTurn();
|
|
9604
|
+
this.redeliverParked();
|
|
9605
|
+
for (const t of this.tasks.values()) if (t.status === "running" && t.deliveryParked === "barge") t.deliveryParked = void 0;
|
|
9606
|
+
return { text: "", steps: 0, finishReason: "stop", messages: [] };
|
|
9607
|
+
});
|
|
9608
|
+
}
|
|
9609
|
+
sendTurn(content, keepParked = false, rec, ext) {
|
|
8438
9610
|
const spec = this.spec;
|
|
8439
9611
|
if (spec?.state === "pending") {
|
|
8440
9612
|
if (typeof content === "string" && speculationConfirms(spec.text, content)) {
|
|
8441
9613
|
spec.state = "confirmed";
|
|
8442
9614
|
spec.finalText = content;
|
|
9615
|
+
if (rec) {
|
|
9616
|
+
spec.rec = rec;
|
|
9617
|
+
rec.ctl.signal.addEventListener("abort", () => spec.ctl.abort(), { once: true });
|
|
9618
|
+
}
|
|
8443
9619
|
for (const ev of spec.buf.splice(0)) this.options.host?.notify?.(ev);
|
|
8444
9620
|
spec.decide("confirm");
|
|
8445
9621
|
this.notify("diag", "speculation_confirmed", { text: spec.text.slice(0, 80) });
|
|
@@ -8451,12 +9627,32 @@ ${nowLine()}. Anchor every relative or time-sensitive reference \u2014 "today",
|
|
|
8451
9627
|
await this.initMemory();
|
|
8452
9628
|
this.resetTurn();
|
|
8453
9629
|
this.turn++;
|
|
8454
|
-
|
|
8455
|
-
|
|
8456
|
-
|
|
8457
|
-
|
|
8458
|
-
|
|
8459
|
-
|
|
9630
|
+
if (!keepParked) {
|
|
9631
|
+
this.parkedRedeliver = [];
|
|
9632
|
+
for (const t of this.tasks.values()) if (t.deliveryParked === "barge") t.deliveryParked = "moved_on";
|
|
9633
|
+
}
|
|
9634
|
+
if (rec?.superseded) {
|
|
9635
|
+
this.notify("diag", "turn_replaced", { ran: false });
|
|
9636
|
+
return _DuplexAgent.replacedResult(this.voice.transcript);
|
|
9637
|
+
}
|
|
9638
|
+
const signal = rec && (ext ? AbortSignal.any([ext, rec.ctl.signal]) : rec.ctl.signal);
|
|
9639
|
+
if (signal) {
|
|
9640
|
+
this.voice.options.signal = signal;
|
|
9641
|
+
this.turnSignal = { own: signal, ext };
|
|
9642
|
+
}
|
|
9643
|
+
let res;
|
|
9644
|
+
try {
|
|
9645
|
+
res = await this.voice.send(content);
|
|
9646
|
+
} finally {
|
|
9647
|
+
if (signal) {
|
|
9648
|
+
this.voice.options.signal = ext;
|
|
9649
|
+
this.turnSignal = void 0;
|
|
9650
|
+
}
|
|
9651
|
+
if (rec) rec.done = true;
|
|
9652
|
+
}
|
|
9653
|
+
if (rec?.superseded) return this.dropReplacedTurn();
|
|
9654
|
+
if (rec && res.finishReason === "aborted" && !this.turnDispatched && !this.turnSpoken.trim()) rec.dropped = this.voice.transcript.slice(this.turnBase);
|
|
9655
|
+
await this.endTurn(res);
|
|
8460
9656
|
return res;
|
|
8461
9657
|
});
|
|
8462
9658
|
}
|
|
@@ -8497,7 +9693,7 @@ ${nowLine()}. Anchor every relative or time-sensitive reference \u2014 "today",
|
|
|
8497
9693
|
try {
|
|
8498
9694
|
res = await this.voice.send(spec.text);
|
|
8499
9695
|
} catch (e) {
|
|
8500
|
-
|
|
9696
|
+
log21.warn(`speculative turn failed: ${e instanceof Error ? e.message : e}`);
|
|
8501
9697
|
} finally {
|
|
8502
9698
|
this.voice.options.signal = prevSignal;
|
|
8503
9699
|
}
|
|
@@ -8511,7 +9707,7 @@ ${nowLine()}. Anchor every relative or time-sensitive reference \u2014 "today",
|
|
|
8511
9707
|
if (d === "abort") {
|
|
8512
9708
|
if (this.voice.transcript.length > base) this.voice.transcript.length = base;
|
|
8513
9709
|
this.speculativeAbortedCalls++;
|
|
8514
|
-
|
|
9710
|
+
log21.verbose(`speculation aborted (${this.speculativeAbortedCalls} total): "${spec.text.slice(0, 50)}"`);
|
|
8515
9711
|
if (this.spec === spec) this.spec = void 0;
|
|
8516
9712
|
return res ?? empty;
|
|
8517
9713
|
}
|
|
@@ -8523,8 +9719,9 @@ ${nowLine()}. Anchor every relative or time-sensitive reference \u2014 "today",
|
|
|
8523
9719
|
}
|
|
8524
9720
|
}
|
|
8525
9721
|
this.spec = void 0;
|
|
8526
|
-
|
|
8527
|
-
if (
|
|
9722
|
+
if (spec.rec) spec.rec.done = true;
|
|
9723
|
+
if (spec.rec?.superseded) return this.dropReplacedTurn();
|
|
9724
|
+
await this.endTurn(res);
|
|
8528
9725
|
return res ?? empty;
|
|
8529
9726
|
});
|
|
8530
9727
|
}
|
|
@@ -8553,37 +9750,125 @@ ${nowLine()}. Anchor every relative or time-sensitive reference \u2014 "today",
|
|
|
8553
9750
|
* superseded topic never talks over the new one (the debt-after-jokes regression). Tasks keep running and
|
|
8554
9751
|
* still fold their result into the transcript — recoverable, just not spoken. Returns parked ids (logging).
|
|
8555
9752
|
* Does NOT cancel. Two callers, two shapes:
|
|
8556
|
-
* - BARGE (default): the user interrupted AUDIO. Park EVERY running task (including this turn's) — the
|
|
8557
|
-
* worker they cut off must stop, whatever turn launched it —
|
|
8558
|
-
*
|
|
8559
|
-
* - SUPERSEDE (`priorTurnsOnly`, `
|
|
8560
|
-
*
|
|
8561
|
-
*
|
|
8562
|
-
parkInFlightDeliveries(
|
|
8563
|
-
if (
|
|
9753
|
+
* - BARGE (default): the user interrupted AUDIO. Park EVERY live running task (including this turn's) — the
|
|
9754
|
+
* worker they cut off must stop, whatever turn launched it — as 'barge', so a following "go on" can bring
|
|
9755
|
+
* it back. A task the user already moved on from stays moved on: a barge never revives it.
|
|
9756
|
+
* - SUPERSEDE (`priorTurnsOnly`, `barge:false`, from dispatch()): a new dispatch redirected to fresh work.
|
|
9757
|
+
* Park only PRIOR-turn live tasks so parallel siblings dispatched in the SAME turn don't silence each other,
|
|
9758
|
+
* as 'moved_on' (don't resurface on a stray "go on"). */
|
|
9759
|
+
parkInFlightDeliveries(barge = true, priorTurnsOnly = false) {
|
|
9760
|
+
if (barge) {
|
|
9761
|
+
this.bargeMark = this.parkedRedeliver.length;
|
|
9762
|
+
this.reflexCut = false;
|
|
9763
|
+
this.offerOpen = false;
|
|
9764
|
+
}
|
|
8564
9765
|
const parked = [];
|
|
8565
9766
|
for (const rec of this.tasks.values())
|
|
8566
9767
|
if (rec.status === "running" && !rec.deliveryParked && (!priorTurnsOnly || rec.createdTurn < this.turn)) {
|
|
8567
|
-
rec.deliveryParked =
|
|
9768
|
+
rec.deliveryParked = barge ? "barge" : "moved_on";
|
|
8568
9769
|
parked.push(rec.id);
|
|
8569
9770
|
}
|
|
8570
9771
|
return parked;
|
|
8571
9772
|
}
|
|
8572
|
-
/**
|
|
8573
|
-
* one
|
|
9773
|
+
/** A barge-in cut a worker delivery the VOICE ENGINE was still speaking — the queued utterances it dropped plus the
|
|
9774
|
+
* unheard rest of the one playing (cli splitCut). It joins the parked delivery AHEAD of whatever parked tasks
|
|
9775
|
+
* produced since the barge (it was voiced first); the user's next line runs the barge-intent judge — resume it,
|
|
9776
|
+
* answer an aside and carry it on, or drop it on a steer/stop. `heard` = the cut utterance's part the user DID hear
|
|
9777
|
+
* (the aside note tells the reflex what was heard vs missed).
|
|
9778
|
+
* Live 20260923-125143: "Oh, that— that's new." cut an iOS-news delivery and its 6 remaining utterances vanished.
|
|
9779
|
+
* `reflexCut` = the barge cut a REFLEX reply (the host's cut had a reply): a "go on" then runs the reflex (see send).
|
|
9780
|
+
* A cut spoken by a task the user moved on from (or whose outcome went to an integration turn) is not parked. */
|
|
9781
|
+
parkCutDelivery(text, reflexCut = false, heard = "") {
|
|
9782
|
+
this.reflexCut = reflexCut;
|
|
9783
|
+
const t = text.trim();
|
|
9784
|
+
if (!t) return;
|
|
9785
|
+
if (this.offer.text && this.offer.text.endsWith(t)) {
|
|
9786
|
+
this.offerOpen = !reflexCut;
|
|
9787
|
+
return;
|
|
9788
|
+
}
|
|
9789
|
+
if (t.split(SENTENCE_SPLIT).every(isQuestion)) return;
|
|
9790
|
+
const first = t.split(/(?<=[.!?])\s/)[0];
|
|
9791
|
+
const task = [...this.voicedBy].reverse().find(([seg]) => t.includes(seg) || first.length >= 16 && seg.includes(first))?.[1];
|
|
9792
|
+
if (task && this.tasks.get(task)?.deliveryParked === "moved_on") return;
|
|
9793
|
+
this.parkedRedeliver.splice(Math.min(this.bargeMark, this.parkedRedeliver.length), 0, { text: t, task, ...heard ? { heard } : {} });
|
|
9794
|
+
this.notify("diag", "delivery_parked", { chars: t.length });
|
|
9795
|
+
}
|
|
9796
|
+
/** One spoken segment of a task's delivery — the ONLY path a worker's speech takes (streamed segments, the settle
|
|
9797
|
+
* tail, the no-spoken fallback). Live → the voice queue; barge-parked → the parked delivery, in order; superseded
|
|
9798
|
+
* (the user moved on) → nowhere (the result still folds into the transcript at settle). */
|
|
9799
|
+
deliverSegment(rec, seg) {
|
|
9800
|
+
if (!seg.trim()) return;
|
|
9801
|
+
if (rec) rec.spokenText = (rec.spokenText ? rec.spokenText + " " : "") + seg;
|
|
9802
|
+
if (!rec?.deliveryParked) {
|
|
9803
|
+
const [say, rest] = rec ? this.budgetSplit(rec, seg) : [seg, ""];
|
|
9804
|
+
if (say) {
|
|
9805
|
+
this.offerOpen = false;
|
|
9806
|
+
this.options.host?.notify?.({ kind: "speak_utterance", message: say });
|
|
9807
|
+
if (rec) {
|
|
9808
|
+
this.voicedBy.set(say.trim(), rec.id);
|
|
9809
|
+
if (this.voicedBy.size > 64) this.voicedBy.delete(this.voicedBy.keys().next().value);
|
|
9810
|
+
}
|
|
9811
|
+
}
|
|
9812
|
+
if (!rest || !rec) return;
|
|
9813
|
+
rec.deliveryParked = "barge";
|
|
9814
|
+
this.parkedRedeliver.push({ text: rest, task: rec.id });
|
|
9815
|
+
if (this.options.spokenOffer) {
|
|
9816
|
+
this.offer = { text: rec.capOffer = this.options.spokenOffer, task: rec.id };
|
|
9817
|
+
this.offerOpen = true;
|
|
9818
|
+
this.options.host?.notify?.({ kind: "speak_utterance", message: this.offer.text });
|
|
9819
|
+
}
|
|
9820
|
+
this.notify("diag", "delivery_budget", { task: rec.id, spoke: rec.voicedChars, parked: rest.length });
|
|
9821
|
+
} else if (rec.deliveryParked === "barge") this.parkedRedeliver.push({ text: seg, task: rec.id });
|
|
9822
|
+
}
|
|
9823
|
+
/** Split a delivery segment at the spoken budget (whole sentences; the first sentence always speaks): what to voice
|
|
9824
|
+
* now, and the rest to park. Marks the budget spent on a cut, so a resumed delivery runs on unbudgeted. */
|
|
9825
|
+
budgetSplit(rec, seg) {
|
|
9826
|
+
const cap = this.options.spokenBudgetChars;
|
|
9827
|
+
if (!cap || rec.budgetSpent) return [seg, ""];
|
|
9828
|
+
const sents = seg.trim().split(SENTENCE_SPLIT);
|
|
9829
|
+
let used = rec.voicedChars ?? 0, n = 0;
|
|
9830
|
+
while (n < sents.length && (!used || used + sents[n].length <= cap)) used += sents[n++].length + 1;
|
|
9831
|
+
const over = sents.slice(n);
|
|
9832
|
+
if (!over.length || over.length === 1 && isQuestion(over[0]) && over[0].length <= OFFER_OVERRUN && used <= cap) {
|
|
9833
|
+
rec.voicedChars = used + (over[0] ? over[0].length + 1 : 0);
|
|
9834
|
+
return [seg, ""];
|
|
9835
|
+
}
|
|
9836
|
+
rec.voicedChars = used;
|
|
9837
|
+
rec.budgetSpent = true;
|
|
9838
|
+
return [sents.slice(0, n).join(" "), sents.slice(n).join(" ")];
|
|
9839
|
+
}
|
|
9840
|
+
/** The cap offer last spoken and the task whose rest it offers — a barge over it is not a delivery to park
|
|
9841
|
+
* (parkCutDelivery). */
|
|
9842
|
+
offer = { text: "", task: "" };
|
|
9843
|
+
/** The cap offer was the last thing voiced (no delivery or turn has spoken since) — a plain yes accepts it (send). */
|
|
9844
|
+
offerOpen = false;
|
|
9845
|
+
/** The task's outcome now belongs to its integration turn (failed / stopped early): take back its parked pieces and
|
|
9846
|
+
* keep it from being revived — a "go on" must not replay raw segments of a result the reflex is about to explain. */
|
|
9847
|
+
dropParked(rec) {
|
|
9848
|
+
this.parkedRedeliver = this.parkedRedeliver.filter((p) => p.task !== rec.id);
|
|
9849
|
+
if (rec.deliveryParked) rec.deliveryParked = "moved_on";
|
|
9850
|
+
}
|
|
9851
|
+
/** True while there is a parked delivery the next line may resume: unheard text already parked, or a barge-parked
|
|
9852
|
+
* task still running (one the user moved on from does not count). */
|
|
8574
9853
|
hasParkedDelivery() {
|
|
8575
|
-
return this.parkedRedeliver.length > 0 || [...this.tasks.values()].some((t) => t.status === "running" && t.deliveryParked);
|
|
8576
|
-
}
|
|
8577
|
-
/**
|
|
8578
|
-
*
|
|
9854
|
+
return this.parkedRedeliver.length > 0 || [...this.tasks.values()].some((t) => t.status === "running" && t.deliveryParked === "barge");
|
|
9855
|
+
}
|
|
9856
|
+
/** The note for an aside over a parked delivery: the same reply-interruption note a cut reflex reply gets
|
|
9857
|
+
* (interruptionNote), over the delivery as it was being said — the cut utterance's heard part, then every unheard
|
|
9858
|
+
* piece. Nothing parked yet (the task is still working) → its result simply follows the answer. */
|
|
9859
|
+
asideNote(told) {
|
|
9860
|
+
const rest = told.map((p) => p.text).join(" ").trim();
|
|
9861
|
+
if (!rest) return "\n[you were in the middle of delivering a task result \u2014 answer this briefly; the rest of the result follows right after your answer. If their words are an unfinished fragment, call Hold instead.]";
|
|
9862
|
+
const heard = told[0]?.heard ?? "";
|
|
9863
|
+
return interruptionNote({ full: heard + rest, heard }, "aside");
|
|
9864
|
+
}
|
|
9865
|
+
/** Speak the parked delivery now (resume), in order, and clear it. */
|
|
8579
9866
|
redeliverParked() {
|
|
8580
|
-
|
|
8581
|
-
|
|
8582
|
-
|
|
8583
|
-
|
|
8584
|
-
|
|
8585
|
-
}
|
|
8586
|
-
return true;
|
|
9867
|
+
const text = this.parkedRedeliver.splice(0).map((p) => p.text).join(" ").trim();
|
|
9868
|
+
if (!text) return;
|
|
9869
|
+
this.offerOpen = false;
|
|
9870
|
+
this.options.host?.notify?.({ kind: "speak_utterance", message: text });
|
|
9871
|
+
this.notify("diag", "parked_redelivered", { chars: text.length });
|
|
8587
9872
|
}
|
|
8588
9873
|
/** Resolve when all queued voice turns AND all in-flight worker tasks have settled (tests, graceful shutdown). */
|
|
8589
9874
|
async idle() {
|
|
@@ -8639,9 +9924,8 @@ ${nowLine()}. Anchor every relative or time-sensitive reference \u2014 "today",
|
|
|
8639
9924
|
const failed = events.find((e) => /^\[task\b[^\]\n]*\bfailed\b/i.test(e));
|
|
8640
9925
|
this.resetTurn();
|
|
8641
9926
|
this.turnFollowUp = nonCleanTurn;
|
|
8642
|
-
await this.voice.send(events.join("\n"));
|
|
8643
|
-
this.
|
|
8644
|
-
if (this.silentTurn) await this.ackIfSilent(failed ? "Sorry, that didn't work \u2014 the task failed." : void 0);
|
|
9927
|
+
const res = await this.voice.send(events.join("\n"));
|
|
9928
|
+
await this.endTurn(res, failed ? "Sorry, that didn't work \u2014 the task failed." : void 0);
|
|
8645
9929
|
this.notify("revoice_done", "");
|
|
8646
9930
|
});
|
|
8647
9931
|
}
|
|
@@ -8649,13 +9933,14 @@ ${nowLine()}. Anchor every relative or time-sensitive reference \u2014 "today",
|
|
|
8649
9933
|
* Act briefs get a self-verify footer — the worker's report is trusted without review, so it
|
|
8650
9934
|
* must check its own work before reporting (nearly free under prompt caching; measured honest:
|
|
8651
9935
|
* it does NOT fix one-shot logic bugs — see mind/10). Think tasks are pure reasoning — no footer. */
|
|
9936
|
+
/** Static text snapshot of the last `turns` user/assistant messages (`role: text` lines). */
|
|
9937
|
+
recentExcerpt(turns = this.options.excerptTurns) {
|
|
9938
|
+
return this.voice.transcript.filter((m) => (m.role === "user" || m.role === "assistant") && contentText(m.content).trim()).slice(-turns).map((m) => `${m.role}: ${contentText(m.content)}`).join("\n");
|
|
9939
|
+
}
|
|
8652
9940
|
buildBrief(brief, tier = "act", deliver = true) {
|
|
8653
|
-
const recent = this.
|
|
9941
|
+
const recent = this.recentExcerpt();
|
|
8654
9942
|
const verify = tier === "act" ? "\n\nBefore reporting done: re-read what you changed and check it against EVERY requirement above \u2014 fix any gap first. Your report is trusted without review." : "";
|
|
8655
|
-
const deliverContract = deliver ? `
|
|
8656
|
-
|
|
8657
|
-
## DELIVER (spoken delivery)
|
|
8658
|
-
You are reporting back to a user who is LISTENING. Stream your work normally \u2014 your prose is the written work record and detail, and is NOT spoken. Wrap anything the user should HEAR in <spoken>\u2026</spoken> tags. LEAD WITH the actual content they asked for: if they asked for a specific piece of content \u2014 a value, a name, the actual lines, the writing itself \u2014 that content goes INSIDE the <spoken> tags, not a remark about it. Your FIRST <spoken> segment is substantive \u2014 never a greeting or an acknowledgement (the front-end has already acked; do not double-ack). Keep spoken text concise and natural for the ear: short sentences, no markdown. NEVER enumerate in speech \u2014 no numbered or bulleted lists ("One. \u2026 Two. \u2026" is robotic). Deliver multiple items as flowing conversation with brief connective phrasing ("here's one\u2026", "and another\u2026", "oh, and\u2026"), pausing between items with sentence breaks, not numbers.` + (this.options.emotionTags ? " Inside <spoken>, you may prefix a sentence with an inline [emotion] tag (e.g. [excited], [curious]) to color how it is voiced \u2014 only when it genuinely fits, and vary it; [laughter] gives a natural laugh." : "") : "";
|
|
9943
|
+
const deliverContract = deliver ? "\n\n## DELIVER (spoken delivery)\nYou are reporting back to a user who is LISTENING. Stream your work normally \u2014 your prose is the written work record and detail, and is NOT spoken. Wrap anything the user should HEAR in <spoken>\u2026</spoken> tags. LEAD WITH the actual content they asked for: if they asked for a specific piece of content \u2014 a value, a name, the actual lines, the writing itself \u2014 that content goes INSIDE the <spoken> tags, not a remark about it. Your FIRST <spoken> segment is substantive \u2014 never a greeting or an acknowledgement (the front-end has already acked; do not double-ack). Keep spoken text concise and natural for the ear: short sentences, no markdown. " + (this.options.spokenBudgetChars ? `Speak a SHORT summary only: at most three short sentences with the one thing that matters most, then ONE short offer that names what else you have, as a question they can answer in a word \u2014 under ${this.options.spokenBudgetChars} characters in all, the offer included. Everything else stays in your written prose (it is kept and they can ask for it); spoken text past that budget is cut and held until they ask. ` : "") + `NEVER enumerate in speech \u2014 no numbered or bulleted lists ("One. \u2026 Two. \u2026" is robotic). Deliver multiple items as flowing conversation with brief connective phrasing ("here's one\u2026", "and another\u2026", "oh, and\u2026"), pausing between items with sentence breaks, not numbers.` + (this.options.emotionTags ? " Inside <spoken>, you may prefix a sentence with an inline [emotion] tag (e.g. [excited], [curious]) to color how it is voiced \u2014 only when it genuinely fits, and vary it; [laughter] gives a natural laugh." : "") : "";
|
|
8659
9944
|
return `${nowLine()}.
|
|
8660
9945
|
|
|
8661
9946
|
` + (recent ? `${brief}
|
|
@@ -8701,12 +9986,7 @@ ${recent}` : brief) + verify + deliverContract;
|
|
|
8701
9986
|
return a || "(no answer from the user \u2014 use your best judgment and note the assumption)";
|
|
8702
9987
|
};
|
|
8703
9988
|
const splitter = new SpokenSplitter();
|
|
8704
|
-
const speak = (seg) =>
|
|
8705
|
-
if (!seg) return;
|
|
8706
|
-
const r = this.tasks.get(id);
|
|
8707
|
-
if (r) r.spokenText = (r.spokenText ? r.spokenText + " " : "") + seg;
|
|
8708
|
-
if (!r?.deliveryParked) o.host?.notify?.({ kind: "speak_utterance", message: seg });
|
|
8709
|
-
};
|
|
9989
|
+
const speak = (seg) => this.deliverSegment(this.tasks.get(id), seg);
|
|
8710
9990
|
const coalescer = new SentenceCoalescer();
|
|
8711
9991
|
const feedSpoken = (s) => {
|
|
8712
9992
|
const ready = coalescer.feed(s);
|
|
@@ -8720,6 +10000,7 @@ ${recent}` : brief) + verify + deliverContract;
|
|
|
8720
10000
|
if (ev?.kind === "text_delta" && typeof ev.message === "string") {
|
|
8721
10001
|
const { spoken, detail } = splitter.feed(ev.message);
|
|
8722
10002
|
feedSpoken(spoken);
|
|
10003
|
+
if (!splitter.inSpoken) flushSpoken();
|
|
8723
10004
|
if (detail.trim()) pushTail(detail.trim());
|
|
8724
10005
|
return;
|
|
8725
10006
|
}
|
|
@@ -8776,7 +10057,7 @@ Another agent just implemented the above. Independently check the CURRENT state
|
|
|
8776
10057
|
this.notify("task_verify", `task ${id}: verifying`, { id });
|
|
8777
10058
|
const cres = await new Agent(checkerOpts).run(checkBrief);
|
|
8778
10059
|
if (cres.finishReason !== "stop") {
|
|
8779
|
-
|
|
10060
|
+
log21.warn(`task ${id}: verify inconclusive (${cres.finishReason})`);
|
|
8780
10061
|
this.notify("task_verify", `task ${id}: verify inconclusive (${cres.finishReason})`, { id, finishReason: cres.finishReason });
|
|
8781
10062
|
}
|
|
8782
10063
|
const sum = (a = 0, b = 0) => a + b;
|
|
@@ -8807,7 +10088,7 @@ Another agent just implemented the above. Independently check the CURRENT state
|
|
|
8807
10088
|
const due = () => {
|
|
8808
10089
|
if (this.pendingAsks.size) return void 0;
|
|
8809
10090
|
const rec = this.tasks.get(id);
|
|
8810
|
-
return rec && rec.status === "running" && Date.now() - lastAt >= this.options.progressIntervalMs ? rec : void 0;
|
|
10091
|
+
return rec && rec.status === "running" && !rec.deliveryParked && Date.now() - lastAt >= this.options.progressIntervalMs ? rec : void 0;
|
|
8811
10092
|
};
|
|
8812
10093
|
const emit = (rec, line, call) => {
|
|
8813
10094
|
lastAt = Date.now();
|
|
@@ -8912,7 +10193,7 @@ Another agent just implemented the above. Independently check the CURRENT state
|
|
|
8912
10193
|
rec.status = "done";
|
|
8913
10194
|
rec.result = res.text;
|
|
8914
10195
|
const incomplete = res.finishReason !== "stop" && res.finishReason !== "empty";
|
|
8915
|
-
|
|
10196
|
+
log21.verbose(`task ${id} done (${res.steps} steps${incomplete ? `, INCOMPLETE: ${res.finishReason}` : ""})`);
|
|
8916
10197
|
this.notify("task_done", `task ${id} (${rec.label}) completed`, {
|
|
8917
10198
|
id,
|
|
8918
10199
|
text: res.text,
|
|
@@ -8924,46 +10205,39 @@ Another agent just implemented the above. Independently check the CURRENT state
|
|
|
8924
10205
|
});
|
|
8925
10206
|
if (incomplete) {
|
|
8926
10207
|
if (this.foldIfSuperseded(rec, res.text)) return;
|
|
10208
|
+
this.dropParked(rec);
|
|
8927
10209
|
return this.queueRevoice(this.integrationPrompt(rec, "incomplete", res.text, res.finishReason), true);
|
|
8928
10210
|
}
|
|
8929
10211
|
const tail = rec.splitter?.flush();
|
|
8930
|
-
if (tail?.spoken
|
|
8931
|
-
if (res.text.trim()) this.
|
|
8932
|
-
if (
|
|
8933
|
-
|
|
8934
|
-
|
|
8935
|
-
if (this.awaitTrivialRedeliver) {
|
|
8936
|
-
this.awaitTrivialRedeliver = false;
|
|
8937
|
-
this.options.host?.notify?.({ kind: "speak_utterance", message: gist });
|
|
8938
|
-
this.notify("diag", "parked_redelivered", { chars: gist.length });
|
|
8939
|
-
} else this.parkedRedeliver.push(gist);
|
|
8940
|
-
}
|
|
8941
|
-
}
|
|
8942
|
-
if (!rec.splitter?.spokeAny && res.text.trim() && !rec.deliveryParked)
|
|
8943
|
-
this.options.host?.notify?.({ kind: "speak_utterance", message: res.text });
|
|
10212
|
+
if (tail?.spoken) this.deliverSegment(rec, tail.spoken);
|
|
10213
|
+
if (!rec.splitter?.spokeAny && res.text.trim()) this.deliverSegment(rec, res.text);
|
|
10214
|
+
if (res.text.trim()) this.voice.transcript.push({ role: "assistant", content: res.text + (rec.capOffer ? `
|
|
10215
|
+
|
|
10216
|
+
(Said aloud in place of the rest of the spoken summary: "${rec.capOffer}")` : "") });
|
|
8944
10217
|
}
|
|
8945
10218
|
onWorkerFailed(id, err) {
|
|
8946
10219
|
this.failTask(this.tasks.get(id), err instanceof Error ? err.message : String(err));
|
|
8947
10220
|
}
|
|
8948
|
-
/** A delivery the user has SUPERSEDED —
|
|
10221
|
+
/** A delivery the user has SUPERSEDED — moved on from (deliveryParked 'moved_on', set by a redirect) —
|
|
8949
10222
|
* must not hijack the voice when it settles late (partial or failed): that would talk over the new topic
|
|
8950
10223
|
* and, worse, auto-escalate work the user already abandoned. Fold a recoverable note into the transcript
|
|
8951
10224
|
* instead (the reflex can surface it if asked) and report true so the caller skips queueRevoice. A merely
|
|
8952
|
-
* barge-parked task (
|
|
10225
|
+
* barge-parked task ('barge' — a "go on" may still resume it) is NOT superseded: it
|
|
8953
10226
|
* keeps the normal surfacing path so a failure the user still cares about is not swallowed. */
|
|
8954
10227
|
foldIfSuperseded(rec, note) {
|
|
8955
|
-
if (
|
|
10228
|
+
if (rec.deliveryParked !== "moved_on") return false;
|
|
8956
10229
|
if (note.trim()) this.voice.transcript.push({ role: "assistant", content: note });
|
|
8957
|
-
|
|
10230
|
+
log21.verbose(`task ${rec.id} settled but superseded (${rec.status}) \u2014 folded into transcript, not voiced`);
|
|
8958
10231
|
return true;
|
|
8959
10232
|
}
|
|
8960
10233
|
failTask(rec, msg) {
|
|
8961
10234
|
this.dropAsk(rec.id);
|
|
8962
10235
|
rec.status = "error";
|
|
8963
10236
|
rec.result = msg;
|
|
8964
|
-
|
|
10237
|
+
log21.warn(`task ${rec.id} failed: ${msg}`);
|
|
8965
10238
|
this.notify("task_error", `task ${rec.id} (${rec.label}) failed: ${msg}`);
|
|
8966
10239
|
if (this.foldIfSuperseded(rec, `The "${rec.label}" task failed: ${msg}`)) return;
|
|
10240
|
+
this.dropParked(rec);
|
|
8967
10241
|
this.queueRevoice(this.integrationPrompt(rec, "error", msg, "error"), true);
|
|
8968
10242
|
}
|
|
8969
10243
|
// --- voice tools (closures over this instance) ---
|
|
@@ -8992,10 +10266,18 @@ Another agent just implemented the above. Independently check the CURRENT state
|
|
|
8992
10266
|
this.notify("task_started", `task ${id} (${lbl}) started`, { id, brief, tier });
|
|
8993
10267
|
return id;
|
|
8994
10268
|
}
|
|
10269
|
+
/** Tool result for a dispatch. The task is ALREADY RUNNING — live, the terse "Acknowledge briefly" drew
|
|
10270
|
+
* "Did you want me to look up today's news?" right after dispatching exactly that (session 20260923-101910
|
|
10271
|
+
* msg 18). Also truthful about delivery: a clean result is spoken by the worker itself and lands in
|
|
10272
|
+
* context (TaskStatus returns it); only failures come back as a [task … failed] event. */
|
|
10273
|
+
dispatchResult(id, verb) {
|
|
10274
|
+
const after = `The worker speaks its result to the user itself when done (TaskStatus ${id} returns it).`;
|
|
10275
|
+
return this.spokeBeforeDispatch ? `${verb} task ${id} \u2014 it is already running. You already acknowledged \u2014 end your turn now with NO further text. ${after}` : `${verb} task ${id} \u2014 it is already running. Now tell the user in ONE short sentence that you are on it (what you are doing) \u2014 do NOT ask whether they want it; it is already happening. ${after}`;
|
|
10276
|
+
}
|
|
8995
10277
|
actTool() {
|
|
8996
10278
|
return {
|
|
8997
10279
|
name: "Act",
|
|
8998
|
-
description: 'Escalate real work (reading/editing files, searching, running tasks, building) to a standard background worker. Returns immediately with a task id; the result
|
|
10280
|
+
description: 'Escalate real work (reading/editing files, searching, running tasks, building) to a standard background worker. Returns immediately with a task id; when it finishes the worker speaks its result to the user itself (failures arrive as a "[task <id> failed]" event). Provide a clear, self-contained `brief` (the worker does not hear the live conversation).',
|
|
8999
10281
|
parameters: {
|
|
9000
10282
|
type: "object",
|
|
9001
10283
|
required: ["brief"],
|
|
@@ -9010,7 +10292,7 @@ Another agent just implemented the above. Independently check the CURRENT state
|
|
|
9010
10292
|
this.turnBriefs.add(String(brief ?? ""));
|
|
9011
10293
|
this.voice.options.toolChoice = "none";
|
|
9012
10294
|
const id = await this.dispatch(String(brief ?? ""), "act", label ? String(label) : void 0, this.turnFollowUp);
|
|
9013
|
-
return this.
|
|
10295
|
+
return this.dispatchResult(id, "Acting on");
|
|
9014
10296
|
}
|
|
9015
10297
|
};
|
|
9016
10298
|
}
|
|
@@ -9032,7 +10314,7 @@ Another agent just implemented the above. Independently check the CURRENT state
|
|
|
9032
10314
|
this.turnBriefs.add(String(brief ?? ""));
|
|
9033
10315
|
this.voice.options.toolChoice = "none";
|
|
9034
10316
|
const id = await this.dispatch(String(brief ?? ""), "think", label ? String(label) : void 0, this.turnFollowUp);
|
|
9035
|
-
return this.
|
|
10317
|
+
return this.dispatchResult(id, "Thinking on");
|
|
9036
10318
|
}
|
|
9037
10319
|
};
|
|
9038
10320
|
}
|
|
@@ -9044,7 +10326,13 @@ Another agent just implemented the above. Independently check the CURRENT state
|
|
|
9044
10326
|
run: async ({ id }) => {
|
|
9045
10327
|
const list = id ? [this.tasks.get(String(id))].filter(Boolean) : [...this.tasks.values()];
|
|
9046
10328
|
if (!list.length) return id ? `No task '${id}'.` : "No background tasks.";
|
|
9047
|
-
|
|
10329
|
+
const cap = id ? 1500 : 300;
|
|
10330
|
+
const clip = (x) => x.length > cap ? `${x.slice(0, cap)}\u2026 (truncated)` : x;
|
|
10331
|
+
return list.map((t) => {
|
|
10332
|
+
const head = `${t.id} (${t.label}): ${t.status}`;
|
|
10333
|
+
const body = (t.status === "done" ? t.spokenText?.trim() || t.result?.trim() : t.status === "error" ? t.result?.trim() : "") ?? "";
|
|
10334
|
+
return body ? `${head}. ${t.status === "error" ? "Error" : "Result"}: ${clip(body)}` : head;
|
|
10335
|
+
}).join("\n");
|
|
9048
10336
|
}
|
|
9049
10337
|
};
|
|
9050
10338
|
}
|
|
@@ -9056,18 +10344,20 @@ Another agent just implemented the above. Independently check the CURRENT state
|
|
|
9056
10344
|
const kinds = [.../* @__PURE__ */ new Set(["time", "branch", "ls", "file", "capabilities", ...Object.keys(this.options.quickLook ?? {})])];
|
|
9057
10345
|
return {
|
|
9058
10346
|
name: "QuickLook",
|
|
9059
|
-
description: `Instant read-only lookup \u2014 one of: ${kinds.join(", ")}. For trivial facts only; anything needing search, commands, or reasoning goes through Act
|
|
10347
|
+
description: `Instant read-only lookup \u2014 one of: ${kinds.join(", ")}. ` + (kinds.includes("sessions") ? '`sessions` = EARLIER conversations with this user: what:"sessions" lists them; what:"sessions" with path:"<session id>" reads one (not what:"file"). ' : "") + "For trivial facts only; anything needing search, commands, or reasoning goes through Act.",
|
|
9060
10348
|
parameters: {
|
|
9061
10349
|
type: "object",
|
|
9062
10350
|
required: ["what"],
|
|
9063
10351
|
properties: {
|
|
9064
10352
|
what: { type: "string", enum: kinds, description: "what to look up" },
|
|
9065
|
-
path: { type: "string", description:
|
|
10353
|
+
path: { type: "string", description: 'for ls/file: the path to look at; for sessions: a session id (or "previous")' }
|
|
9066
10354
|
}
|
|
9067
10355
|
},
|
|
9068
10356
|
run: async ({ what, path }) => {
|
|
9069
10357
|
const fs = this.options.fs;
|
|
9070
10358
|
try {
|
|
10359
|
+
const sessions = this.options.quickLook?.sessions;
|
|
10360
|
+
if (sessions && (what === "file" || what === "ls") && SESSION_ID_RE.test(String(path ?? "").trim())) return await sessions(String(path).trim());
|
|
9071
10361
|
const over = this.options.quickLook?.[String(what)];
|
|
9072
10362
|
if (over) return await over(path ? String(path) : void 0);
|
|
9073
10363
|
switch (String(what)) {
|