@livx.cc/agentx 0.99.57 → 0.99.59
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/{Agent-CXkPfYX2.d.ts → Agent-DqFJubs5.d.ts} +104 -2
- package/dist/cli.d.ts +23 -3
- package/dist/cli.js +2351 -307
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +169 -31
- package/dist/index.js +1722 -186
- package/dist/index.js.map +1 -1
- package/dist/models.js +1 -0
- package/dist/models.js.map +1 -1
- package/package.json +2 -2
package/dist/cli.js
CHANGED
|
@@ -2561,7 +2561,7 @@ async function psGroupCpuSeconds(pgid) {
|
|
|
2561
2561
|
const out = await new Promise((res, rej) => execFile4("ps", ["-A", "-o", "pid=,ppid=,pgid=,time="], { timeout: 5e3, env: { ...process.env, LC_ALL: "C" } }, (e, so) => e ? rej(e) : res(String(so))));
|
|
2562
2562
|
return sumTreeCpu(out, pgid);
|
|
2563
2563
|
} catch (e) {
|
|
2564
|
-
|
|
2564
|
+
log23.debug(`ps cpu probe failed for group ${pgid}: ${e}`);
|
|
2565
2565
|
return void 0;
|
|
2566
2566
|
}
|
|
2567
2567
|
}
|
|
@@ -2693,7 +2693,7 @@ function makeRealShellTool(options) {
|
|
|
2693
2693
|
proc.stdout?.off?.("data", collect);
|
|
2694
2694
|
proc.stderr?.off?.("data", collect);
|
|
2695
2695
|
const id = registry.adopt(cmd, proc, out);
|
|
2696
|
-
finish(handoffFor(timeoutMs, id,
|
|
2696
|
+
finish(handoffFor(timeoutMs, id, clean2(out), !!registry.notifiesOnExit));
|
|
2697
2697
|
};
|
|
2698
2698
|
}
|
|
2699
2699
|
const collect = (chunk) => {
|
|
@@ -2708,14 +2708,14 @@ function makeRealShellTool(options) {
|
|
|
2708
2708
|
proc.stdout?.on("data", collect);
|
|
2709
2709
|
proc.stderr?.on("data", collect);
|
|
2710
2710
|
proc.on("error", (err2) => {
|
|
2711
|
-
if (err2?.name === "AbortError" || ctl.signal.aborted) return finish(reasonFor(timedOut, timeoutMs,
|
|
2712
|
-
|
|
2713
|
-
finish(`[exit 1] ${err2?.message ?? err2}${out ? "\n" +
|
|
2711
|
+
if (err2?.name === "AbortError" || ctl.signal.aborted) return finish(reasonFor(timedOut, timeoutMs, clean2(out)));
|
|
2712
|
+
log23.debug("shell spawn error", err2);
|
|
2713
|
+
finish(`[exit 1] ${err2?.message ?? err2}${out ? "\n" + clean2(out) : ""}`);
|
|
2714
2714
|
});
|
|
2715
2715
|
proc.on("close", (code) => {
|
|
2716
2716
|
flushEmit(ctx);
|
|
2717
|
-
if (ctl.signal.aborted) return finish(reasonFor(timedOut, timeoutMs,
|
|
2718
|
-
const body =
|
|
2717
|
+
if (ctl.signal.aborted) return finish(reasonFor(timedOut, timeoutMs, clean2(out)));
|
|
2718
|
+
const body = clean2(out);
|
|
2719
2719
|
if (code && code !== 0) return finish(`[exit ${code}]${body ? "\n" + body : ""}`);
|
|
2720
2720
|
finish(body || "(command succeeded, no output)");
|
|
2721
2721
|
});
|
|
@@ -2754,7 +2754,7 @@ function makeShellJobTools(registry) {
|
|
|
2754
2754
|
if (out == null) return NO_JOB2(String(id));
|
|
2755
2755
|
const st = registry.status(String(id));
|
|
2756
2756
|
return `[${st.status}${st.exitCode != null ? ` exit ${st.exitCode}` : ""}${st.reason ? `: ${st.reason}` : ""}]
|
|
2757
|
-
${
|
|
2757
|
+
${clean2(out) || "(no output yet)"}`;
|
|
2758
2758
|
}
|
|
2759
2759
|
},
|
|
2760
2760
|
{
|
|
@@ -2782,7 +2782,7 @@ ${clean(out) || "(no output yet)"}`;
|
|
|
2782
2782
|
}
|
|
2783
2783
|
];
|
|
2784
2784
|
}
|
|
2785
|
-
var
|
|
2785
|
+
var log23, clean2, DETACHED, SECRET_ENV_RE, _spawn, ShellJobRegistry, shellStatus, NO_JOB2;
|
|
2786
2786
|
var init_tools_shell = __esm({
|
|
2787
2787
|
"src/tools.shell.ts"() {
|
|
2788
2788
|
"use strict";
|
|
@@ -2791,8 +2791,8 @@ var init_tools_shell = __esm({
|
|
|
2791
2791
|
init_logging();
|
|
2792
2792
|
init_shell_sandbox();
|
|
2793
2793
|
init_tools_jobs();
|
|
2794
|
-
|
|
2795
|
-
|
|
2794
|
+
log23 = forComponent("shell");
|
|
2795
|
+
clean2 = (s) => truncateOutput(redactSecrets(s.replace(/\n+$/, "")));
|
|
2796
2796
|
DETACHED = { stdio: ["ignore", "pipe", "pipe"], detached: true };
|
|
2797
2797
|
SECRET_ENV_RE = /(_API_KEY|_TOKEN|_SECRET|_PASSWORD|_PRIVATE_KEY|^AWS_|^GITHUB_TOKEN$|^OPENAI_|^ANTHROPIC_|^GOOGLE_|^GEMINI_|^GROQ_|^NPM_TOKEN$)/i;
|
|
2798
2798
|
ShellJobRegistry = class {
|
|
@@ -2877,7 +2877,7 @@ var init_tools_shell = __esm({
|
|
|
2877
2877
|
notifyExit(id, meta, status, reason) {
|
|
2878
2878
|
if (!this.cfg.onExit || status === "killed" && !reason?.startsWith("stuck")) return;
|
|
2879
2879
|
try {
|
|
2880
|
-
this.cfg.onExit({ id, command: meta.command, status: shellStatus(status), exitCode: meta.exitCode, tail:
|
|
2880
|
+
this.cfg.onExit({ id, command: meta.command, status: shellStatus(status), exitCode: meta.exitCode, tail: clean2(this.jobs.output(id) ?? "").slice(-4e3), ...reason && status !== "done" ? { reason } : {} });
|
|
2881
2881
|
} catch {
|
|
2882
2882
|
}
|
|
2883
2883
|
}
|
|
@@ -2939,7 +2939,7 @@ var init_tools_shell = __esm({
|
|
|
2939
2939
|
|
|
2940
2940
|
// cli/cli.ts
|
|
2941
2941
|
import { createInterface } from "readline/promises";
|
|
2942
|
-
import { existsSync as existsSync9, readFileSync as readFileSync9, appendFileSync, mkdirSync as mkdirSync12, writeFileSync as writeFileSync9, readdirSync as readdirSync4, statSync as
|
|
2942
|
+
import { existsSync as existsSync9, readFileSync as readFileSync9, appendFileSync, mkdirSync as mkdirSync12, writeFileSync as writeFileSync9, readdirSync as readdirSync4, statSync as statSync6, unlinkSync as unlinkSync5 } from "fs";
|
|
2943
2943
|
import { homedir as homedir10 } from "os";
|
|
2944
2944
|
import { spawnSync as spawnSync6 } from "child_process";
|
|
2945
2945
|
|
|
@@ -3009,7 +3009,7 @@ function copyTextToClipboard(text, platform2 = process.platform) {
|
|
|
3009
3009
|
|
|
3010
3010
|
// cli/cli.ts
|
|
3011
3011
|
import { join as join14, resolve as resolve4, basename as basename2, extname, dirname as dirname5 } from "path";
|
|
3012
|
-
import { AIClient, listModels, listProviders, getProviderFromModel, getModelInfo, resolveModel, isModelSupported, disposeCursorSessions, disposeClaudeCodeSessions } from "ai.libx.js";
|
|
3012
|
+
import { AIClient, DecideClient, listModels, listProviders, getProviderFromModel, getModelInfo, resolveModel, isModelSupported, disposeCursorSessions, disposeClaudeCodeSessions } from "ai.libx.js";
|
|
3013
3013
|
|
|
3014
3014
|
// src/Agent.ts
|
|
3015
3015
|
init_llm();
|
|
@@ -3571,6 +3571,7 @@ function resolveModelAlias(input) {
|
|
|
3571
3571
|
const slash = input.indexOf("/");
|
|
3572
3572
|
const prefix = slash === -1 ? "" : input.slice(0, slash + 1);
|
|
3573
3573
|
const rest = slash === -1 ? input : input.slice(slash + 1);
|
|
3574
|
+
if (prefix === "claude-code/") return input;
|
|
3574
3575
|
return prefix + (MODEL_ALIASES[rest.toLowerCase()] ?? rest);
|
|
3575
3576
|
}
|
|
3576
3577
|
function modelProvider(model) {
|
|
@@ -5524,14 +5525,14 @@ function summarize(messages, focus) {
|
|
|
5524
5525
|
if (lastAssistant) lines.push(`last assistant: ${lastAssistant}`);
|
|
5525
5526
|
if (carried.length) lines.push("earlier recap:", ...carried.map((l) => ` ${l}`));
|
|
5526
5527
|
if (focus?.trim()) {
|
|
5527
|
-
const
|
|
5528
|
+
const words2 = focus.toLowerCase().split(/\s+/).filter((w) => w.length > 2);
|
|
5528
5529
|
const kept = [];
|
|
5529
5530
|
const ordered = [...messages.filter((m) => !isSyntheticSummary(m)), ...messages.filter(isSyntheticSummary)];
|
|
5530
5531
|
outer: for (const msg of ordered) {
|
|
5531
5532
|
if (msg.role === "tool") continue;
|
|
5532
5533
|
for (const line of contentText(msg.content).split("\n")) {
|
|
5533
5534
|
const l = line.toLowerCase();
|
|
5534
|
-
if (line.trim() &&
|
|
5535
|
+
if (line.trim() && words2.some((w) => l.includes(w))) {
|
|
5535
5536
|
kept.push(line.trim().slice(0, 200));
|
|
5536
5537
|
if (kept.length >= 12) break outer;
|
|
5537
5538
|
}
|
|
@@ -6489,6 +6490,130 @@ var EmotionStream = class {
|
|
|
6489
6490
|
}
|
|
6490
6491
|
};
|
|
6491
6492
|
|
|
6493
|
+
// node_modules/@bod.ee/voice/src/core/endpointing.ts
|
|
6494
|
+
var INCOMPLETE_ENDINGS = /* @__PURE__ */ new Set([
|
|
6495
|
+
// Conjunctions
|
|
6496
|
+
"and",
|
|
6497
|
+
"but",
|
|
6498
|
+
"or",
|
|
6499
|
+
"so",
|
|
6500
|
+
"because",
|
|
6501
|
+
"although",
|
|
6502
|
+
"though",
|
|
6503
|
+
"while",
|
|
6504
|
+
"since",
|
|
6505
|
+
"unless",
|
|
6506
|
+
"if",
|
|
6507
|
+
"when",
|
|
6508
|
+
"where",
|
|
6509
|
+
// Prepositions
|
|
6510
|
+
"to",
|
|
6511
|
+
"for",
|
|
6512
|
+
"with",
|
|
6513
|
+
"about",
|
|
6514
|
+
"of",
|
|
6515
|
+
"at",
|
|
6516
|
+
"in",
|
|
6517
|
+
"on",
|
|
6518
|
+
"from",
|
|
6519
|
+
"by",
|
|
6520
|
+
"into",
|
|
6521
|
+
"through",
|
|
6522
|
+
// Articles / determiners
|
|
6523
|
+
"the",
|
|
6524
|
+
"a",
|
|
6525
|
+
"an",
|
|
6526
|
+
"this",
|
|
6527
|
+
"that",
|
|
6528
|
+
"these",
|
|
6529
|
+
"those",
|
|
6530
|
+
"my",
|
|
6531
|
+
"your",
|
|
6532
|
+
"his",
|
|
6533
|
+
"her",
|
|
6534
|
+
"our",
|
|
6535
|
+
"their",
|
|
6536
|
+
// Bare pronouns ("…as I asked it" — the live case above)
|
|
6537
|
+
"i",
|
|
6538
|
+
"you",
|
|
6539
|
+
"we",
|
|
6540
|
+
"they",
|
|
6541
|
+
"he",
|
|
6542
|
+
"she",
|
|
6543
|
+
"it",
|
|
6544
|
+
// Fillers (thinking)
|
|
6545
|
+
"um",
|
|
6546
|
+
"uh",
|
|
6547
|
+
"like",
|
|
6548
|
+
"well"
|
|
6549
|
+
]);
|
|
6550
|
+
var CONTINUATION_MARKERS = [
|
|
6551
|
+
"you know",
|
|
6552
|
+
"i mean",
|
|
6553
|
+
"the thing is",
|
|
6554
|
+
"let me think",
|
|
6555
|
+
"first of all",
|
|
6556
|
+
"on one hand",
|
|
6557
|
+
"for example"
|
|
6558
|
+
];
|
|
6559
|
+
var COMPLETE_ENDINGS = /* @__PURE__ */ new Set([
|
|
6560
|
+
"yeah",
|
|
6561
|
+
"yes",
|
|
6562
|
+
"no",
|
|
6563
|
+
"nah",
|
|
6564
|
+
"okay",
|
|
6565
|
+
"ok",
|
|
6566
|
+
"sure",
|
|
6567
|
+
"right",
|
|
6568
|
+
"thanks",
|
|
6569
|
+
"bye",
|
|
6570
|
+
"absolutely",
|
|
6571
|
+
"definitely",
|
|
6572
|
+
"exactly",
|
|
6573
|
+
"correct",
|
|
6574
|
+
"true",
|
|
6575
|
+
"false"
|
|
6576
|
+
]);
|
|
6577
|
+
function isTranscriptComplete(text) {
|
|
6578
|
+
const trimmed = text.trim();
|
|
6579
|
+
if (!trimmed) return false;
|
|
6580
|
+
if (/[.?!]$/.test(trimmed)) return true;
|
|
6581
|
+
const words2 = trimmed.toLowerCase().split(/\s+/);
|
|
6582
|
+
const lastWord = words2[words2.length - 1];
|
|
6583
|
+
if (COMPLETE_ENDINGS.has(lastWord)) return true;
|
|
6584
|
+
if (INCOMPLETE_ENDINGS.has(lastWord)) return false;
|
|
6585
|
+
const lower = trimmed.toLowerCase();
|
|
6586
|
+
for (const marker of CONTINUATION_MARKERS) if (lower.endsWith(marker)) return false;
|
|
6587
|
+
if (words2.length <= 2) return false;
|
|
6588
|
+
return true;
|
|
6589
|
+
}
|
|
6590
|
+
var MAX_HOLD_MS = 2e3;
|
|
6591
|
+
var MIN_MERGE_HOLD_MS = 800;
|
|
6592
|
+
function endpointTier(transcript) {
|
|
6593
|
+
const t = transcript.trim();
|
|
6594
|
+
if (!isTranscriptComplete(t)) return 1;
|
|
6595
|
+
return /[.?!]$/.test(t) ? 3 : 2;
|
|
6596
|
+
}
|
|
6597
|
+
function tierHoldMs(tier, ctx) {
|
|
6598
|
+
if (tier === 1) return ctx.incompleteMs;
|
|
6599
|
+
if (tier === 2) return Math.round((ctx.baseMs + ctx.incompleteMs) / 2);
|
|
6600
|
+
return ctx.baseMs;
|
|
6601
|
+
}
|
|
6602
|
+
function computeHoldMs(ctx) {
|
|
6603
|
+
const t = ctx.transcript.trim();
|
|
6604
|
+
const tier = endpointTier(t);
|
|
6605
|
+
let ms = tierHoldMs(tier, ctx);
|
|
6606
|
+
const ai = ctx.lastAiMessage?.trim();
|
|
6607
|
+
if (ai) {
|
|
6608
|
+
if (/\?$/.test(ai)) ms -= 300;
|
|
6609
|
+
if (ai.split(/\s+/).length > 60) ms += 400;
|
|
6610
|
+
}
|
|
6611
|
+
const lastWord = t.toLowerCase().split(/\s+/).pop();
|
|
6612
|
+
if (lastWord === "um" || lastWord === "uh" || lastWord === "hmm") ms += 500;
|
|
6613
|
+
const floor = tier === 3 ? ctx.baseMs : Math.max(ctx.baseMs, Math.min(tierHoldMs(tier, ctx), MIN_MERGE_HOLD_MS));
|
|
6614
|
+
return Math.max(floor, Math.min(MAX_HOLD_MS, ms));
|
|
6615
|
+
}
|
|
6616
|
+
|
|
6492
6617
|
// node_modules/@bod.ee/voice/src/core/engine.ts
|
|
6493
6618
|
var log12 = forComponent2("VoiceEngine");
|
|
6494
6619
|
var realClock = {
|
|
@@ -6497,7 +6622,7 @@ var realClock = {
|
|
|
6497
6622
|
clearTimeout: (h) => clearTimeout(h)
|
|
6498
6623
|
};
|
|
6499
6624
|
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, ".");
|
|
6500
|
-
var normWord = (w) => w.toLowerCase().replace(/[
|
|
6625
|
+
var normWord = (w) => w.toLowerCase().replace(/[^\p{L}\p{M}\p{N}]/gu, "");
|
|
6501
6626
|
var SPEC_TRAILING_OK = /* @__PURE__ */ new Set(["please", "thanks", "thank", "you", "now", "okay", "ok", "kindly", "alright", "then", "though", "right", "yeah"]);
|
|
6502
6627
|
function speculationConfirms(spec, final) {
|
|
6503
6628
|
const sw = spec.trim().split(/\s+/).map(normWord).filter(Boolean);
|
|
@@ -6508,11 +6633,27 @@ function speculationConfirms(spec, final) {
|
|
|
6508
6633
|
for (let i = sw.length; i < fw.length; i++) if (!SPEC_TRAILING_OK.has(fw[i])) return false;
|
|
6509
6634
|
return true;
|
|
6510
6635
|
}
|
|
6636
|
+
function heardPrefix(text, spoken, lookahead = 4) {
|
|
6637
|
+
const toks = [...text.matchAll(/\S+/g)];
|
|
6638
|
+
let i = 0, at = 0;
|
|
6639
|
+
for (const w of spoken) {
|
|
6640
|
+
const nw = normWord(w);
|
|
6641
|
+
if (!nw) continue;
|
|
6642
|
+
for (let k = i; k < Math.min(toks.length, i + lookahead); k++)
|
|
6643
|
+
if (normWord(toks[k][0]) === nw) {
|
|
6644
|
+
at = toks[k].index;
|
|
6645
|
+
i = k + 1;
|
|
6646
|
+
break;
|
|
6647
|
+
}
|
|
6648
|
+
}
|
|
6649
|
+
return at;
|
|
6650
|
+
}
|
|
6511
6651
|
var VoiceEngineOptions = class {
|
|
6512
6652
|
stt;
|
|
6513
6653
|
tts;
|
|
6514
6654
|
player;
|
|
6515
|
-
/** a final utterance arrived (endpoint) — host dispatches it as a turn
|
|
6655
|
+
/** a final utterance arrived (endpoint) — host dispatches it as a turn. `meta.supersedes` marks a fuller
|
|
6656
|
+
* re-finalization of the utterance dispatched just before it: replace that turn, don't add one. */
|
|
6516
6657
|
onUtterance = () => {
|
|
6517
6658
|
};
|
|
6518
6659
|
/** live partial transcript while listening (host renders the 🎤 line) */
|
|
@@ -6520,6 +6661,14 @@ var VoiceEngineOptions = class {
|
|
|
6520
6661
|
};
|
|
6521
6662
|
onState = () => {
|
|
6522
6663
|
};
|
|
6664
|
+
/** The assistant's spoken turn finished AUDIBLY — every TTS chunk arrived AND playback drained.
|
|
6665
|
+
* Fires from the same `settle()` that returns the engine to 'listening', just before that state
|
|
6666
|
+
* change. Distinct from the host's `endSpeech()`, which only closes the TEXT stream (audio is
|
|
6667
|
+
* still playing at that point). NOT fired for a turn cut by barge-in — the host gets `onBargeIn`
|
|
6668
|
+
* instead. Also fires for engine-internal speech (adaptive ack, filler, queued utterances), so a
|
|
6669
|
+
* host that only cares about ITS turns should gate on its own open-turn flag. Default no-op. */
|
|
6670
|
+
onSpeechEnd = () => {
|
|
6671
|
+
};
|
|
6523
6672
|
/** user spoke/acted over playback — host aborts the in-flight turn (called AFTER audio is killed).
|
|
6524
6673
|
* phase: 'speaking' = cut mid-speech (real interruption); 'drain' = in the final audio tail
|
|
6525
6674
|
* (normal turn-taking — hosts shouldn't alarm). */
|
|
@@ -6534,6 +6683,14 @@ var VoiceEngineOptions = class {
|
|
|
6534
6683
|
/** the adaptive ack actually fired (host can mark the turn as spoken — e.g. suppress dead-air repair) */
|
|
6535
6684
|
onAdaptiveAck = () => {
|
|
6536
6685
|
};
|
|
6686
|
+
/** HOST-CHOSEN adaptive ack: asked once per armed ack (at beginSpeech(true), i.e. at dispatch) so a slow pick
|
|
6687
|
+
* runs in parallel with the reflex. Return the phrase to speak, or `null` = stay silent this turn. A Promise
|
|
6688
|
+
* still pending when the ack is due gets up to another `adaptiveAckMs` (then the turn stays silent — a wrong
|
|
6689
|
+
* ack is worse than none); a rejection falls back to the default pools. Unset (null) = the engine's own
|
|
6690
|
+
* shape-picked pools (ACKS_NEUTRAL / ACKS_QUESTION). Phrases enter the echo set like the defaults — keep them
|
|
6691
|
+
* short and not a lone word users say on their own ("okay", "yeah"): the ack-squash guard drops a user
|
|
6692
|
+
* utterance that equals the last ack. */
|
|
6693
|
+
pickAck = null;
|
|
6537
6694
|
/** Endpoint merge window (ms): hold an endpointed utterance briefly — if speech resumes (spelled
|
|
6538
6695
|
* letters, mid-thought pauses), the next utterance MERGES instead of dispatching a truncated one
|
|
6539
6696
|
* ("E-L-Y." / "A."). Costs this much latency per turn; 0 disables. */
|
|
@@ -6541,6 +6698,21 @@ var VoiceEngineOptions = class {
|
|
|
6541
6698
|
/** Extended merge window (ms) for utterances that look incomplete (trailing conjunction/filler).
|
|
6542
6699
|
* Gives the user time to finish their thought without triggering a model call. */
|
|
6543
6700
|
incompleteMergeMs = 1500;
|
|
6701
|
+
/** ADAPTIVE endpoint hold (opt-in; default OFF = today's byte-identical two-tier behavior).
|
|
6702
|
+
* The fixed merge tiers (`utteranceMergeMs` vs `incompleteMergeMs`, chosen by a trailing-word
|
|
6703
|
+
* regex, and skipped entirely at ≥4 words) dispatch mid-thought FRAGMENTS as their own turn: live,
|
|
6704
|
+
* "…give me the best" dispatched, got an empty reply, and "…give me the best of it." arrived 766ms
|
|
6705
|
+
* later as a second turn. When true, every endpointed utterance is held for
|
|
6706
|
+
* `computeHoldMs(...)` — sized by linguistic completeness, whether the STT emitted a sentence-final
|
|
6707
|
+
* mark, and what the assistant last said — so the continuation MERGES instead of burning a turn.
|
|
6708
|
+
* COST — this is strictly slower than the default path, never faster: today every ≥4-word utterance
|
|
6709
|
+
* dispatches at 0ms (the fast path below), so adaptive ADDS the whole hold — ~350ms to every
|
|
6710
|
+
* sentence-final utterance, ~925ms to every complete-looking unpunctuated one (the common case),
|
|
6711
|
+
* and up to 2s on a mid-thought fragment. Paid on turns that didn't need it, to stop burning a turn
|
|
6712
|
+
* (empty reply + blank bubble) on the ones that did — hence opt-in. Also changes `onHold` timing:
|
|
6713
|
+
* it now fires on every tier-2/3 hold, not only on `looksIncomplete` (`holdFiller` stays tier-1
|
|
6714
|
+
* only). See core/endpointing.ts. */
|
|
6715
|
+
adaptiveEndpointing = false;
|
|
6544
6716
|
/** Grace window (ms) after an utterance dispatches, during which the user's own trailing audio cannot
|
|
6545
6717
|
* barge the reply it requested. Soniox keeps finalizing partials past <end>; without this they read
|
|
6546
6718
|
* as a barge and abort the fresh turn (live: mid-sentence self-interruption + steps=1→steps=0 double
|
|
@@ -6551,6 +6723,15 @@ var VoiceEngineOptions = class {
|
|
|
6551
6723
|
* while audible (speaking + drain tail), so echo can never become a phantom turn — the right mode
|
|
6552
6724
|
* when there's no AEC (e.g. the non-VPIO mic fallback) and no headphones. Cost: can't interrupt. */
|
|
6553
6725
|
bargeIn = true;
|
|
6726
|
+
/** Which barge-in TIER to run — the host's override of the mic's self-report.
|
|
6727
|
+
* - `'auto'` (default) — trust `stt.usingAec`: AEC tier when the mic reports echo cancellation
|
|
6728
|
+
* applied, heuristic/energy tier otherwise. Unchanged legacy behavior.
|
|
6729
|
+
* - `'aec'` — force the AEC tier (STT stays live through TTS; barge decided by the dominant-novel
|
|
6730
|
+
* gate + overlap pause) even if the mic reports no echo cancellation.
|
|
6731
|
+
* - `'energy'` — force the heuristic tier (echo-baseline EMA spike arms `suspectUntil`, novel-word
|
|
6732
|
+
* count decides; STT buffer reset after our own audio) even if the mic REPORTS echoCancellation.
|
|
6733
|
+
* Mobile Safari reports `true` while cancelling poorly — that report is what this overrides. */
|
|
6734
|
+
bargeInMode = "auto";
|
|
6554
6735
|
/** Filler phrase spoken when holding for an incomplete utterance ('' disables). */
|
|
6555
6736
|
holdFiller = "";
|
|
6556
6737
|
/** Called when the engine holds an incomplete utterance (host can render a visual cue). */
|
|
@@ -6574,6 +6755,15 @@ var VoiceEngineOptions = class {
|
|
|
6574
6755
|
* Mechanism-based discriminator: a re-PAUSE this soon after a resume = a persistent human, not an
|
|
6575
6756
|
* echo blip (which pauses once and stalls). Cede on the re-pause regardless of the novel gate. */
|
|
6576
6757
|
overlapRepauseCedeMs = 1500;
|
|
6758
|
+
/** HOST-DECIDED short overlaps (opt-in; 0 = off = cede on ≥2 genuine words, as always). The fast "≥2 novel words →
|
|
6759
|
+
* cede" rule cannot tell a reaction ("Oh, that's new") from a barge, and it fires ~300ms before a host-side judge
|
|
6760
|
+
* (e.g. an AI instinct) can answer — so a judge could never keep a delivery going (live 20260923-125143: ceded on
|
|
6761
|
+
* "Oh, that", the judge said keep_talking 300ms later). When > 0, a SHORT genuine overlap (≤ overlapDecideMaxWords,
|
|
6762
|
+
* no '?', no stop/question word) keeps the pause and waits for `decideOverlap()`; with no decision after this many
|
|
6763
|
+
* ms it cedes. Long or obvious barges still cede instantly. Audio is already paused, so waiting costs no talk-over. */
|
|
6764
|
+
overlapDecideMs = 0;
|
|
6765
|
+
/** Longest overlap (words) the host decides; also how far an overlap the host kept may grow before it cedes. */
|
|
6766
|
+
overlapDecideMaxWords = 4;
|
|
6577
6767
|
/** Speculative ENERGY pre-pause while speaking (AEC tier): two residue gate-passes within 350ms →
|
|
6578
6768
|
* pause ~300ms before the STT tokens land. But energy CANNOT separate residue bursts from speech
|
|
6579
6769
|
* (the documented whack-a-mole) — so a residue spike during loud playback false-pauses with NO user
|
|
@@ -6626,6 +6816,10 @@ var VoiceEngineOptions = class {
|
|
|
6626
6816
|
* timestamp-emitting TTS (CartesiaTTS.wantTimestamps) driven over `AudioSink.playedMs`.
|
|
6627
6817
|
*/
|
|
6628
6818
|
revealMode = "off";
|
|
6819
|
+
/** Locate EXACTLY what the user heard when a barge cuts speech: request word timestamps from the TTS (when it
|
|
6820
|
+
* supports them) and match them against the playback clock (InterruptedReply `heard`/`exact`). Independent of
|
|
6821
|
+
* `revealMode` — no reveal events. Without timestamps the cut falls back to the ~15 chars/s estimate. */
|
|
6822
|
+
heardTimestamps = true;
|
|
6629
6823
|
/** Called with the cumulative revealed text for the current assistant turn (see `revealMode`).
|
|
6630
6824
|
* Reset to '' at the start of each spoken turn. */
|
|
6631
6825
|
onReveal = () => {
|
|
@@ -6656,8 +6850,27 @@ var VoiceEngine = class _VoiceEngine {
|
|
|
6656
6850
|
// cumulative revealed (on-screen) text for the current turn — see revealMode
|
|
6657
6851
|
// 'word' karaoke reveal: spoken-word start times (sec, absolute from turn-audio start) + poll state.
|
|
6658
6852
|
wordStarts = [];
|
|
6853
|
+
wordTexts = [];
|
|
6854
|
+
// the timestamped words, parallel to wordStarts (heard-cut matching)
|
|
6855
|
+
ackWords = 0;
|
|
6856
|
+
// leading timestamped words that are the ack spoken on this turn's context, not the reply
|
|
6659
6857
|
revealedN = 0;
|
|
6660
6858
|
revealPoll = null;
|
|
6859
|
+
// Sub-turn timestamp anchoring. A TTS context emits word starts relative to ITS OWN audio start, so
|
|
6860
|
+
// `wordStarts` (one flat turn timeline) is only valid while the turn stays on one context. It does
|
|
6861
|
+
// not: `subturnBoundary()` rotates the context, and Cartesia ALSO rotates it on its own (~5s idle
|
|
6862
|
+
// timeout mid tool-call) — after which starts restart at 0 and every remaining word instantly
|
|
6863
|
+
// satisfies the reveal predicate, dumping the whole segment on screen.
|
|
6864
|
+
// Fix is deterministic, not detective: at a boundary we FORCE the rotation ourselves, so the new
|
|
6865
|
+
// context's origin is always known — it is the turn-audio position where its first byte lands,
|
|
6866
|
+
// captured from the sink at that byte (`playedMs + drainMs` = content seconds written so far).
|
|
6867
|
+
// Starts arriving before that byte are held in `wordPending` and rebased when it does.
|
|
6868
|
+
wordOrigin = 0;
|
|
6869
|
+
// sec offset added to incoming starts (0 = first sub-turn)
|
|
6870
|
+
wordAnchorPending = false;
|
|
6871
|
+
// awaiting the new sub-turn's first audio byte
|
|
6872
|
+
wordPending = [];
|
|
6873
|
+
// [word, start] received while pending, rebased on capture
|
|
6661
6874
|
clock;
|
|
6662
6875
|
drainTimer = null;
|
|
6663
6876
|
// heuristic tier state (inert under AEC) — frozen as validated in the experiment
|
|
@@ -6688,6 +6901,8 @@ var VoiceEngine = class _VoiceEngine {
|
|
|
6688
6901
|
// endpointed text held for the merge window
|
|
6689
6902
|
mergePath = "direct";
|
|
6690
6903
|
// how the pending utterance was assembled (diag)
|
|
6904
|
+
holdTier = 0;
|
|
6905
|
+
// adaptive tier of the hold this dispatch came out of (diag; 0 = none)
|
|
6691
6906
|
lastGraceDiag = 0;
|
|
6692
6907
|
// grace_suppress emitted once per grace window
|
|
6693
6908
|
pendingTimer = null;
|
|
@@ -6698,10 +6913,24 @@ var VoiceEngine = class _VoiceEngine {
|
|
|
6698
6913
|
// the agent replied (or after 3s) still dispatches.
|
|
6699
6914
|
lastDispatchFlat = "";
|
|
6700
6915
|
lastDispatchWords = [];
|
|
6916
|
+
lastDispatchText = "";
|
|
6701
6917
|
lastDispatchAt = 0;
|
|
6702
6918
|
repliedSinceDispatch = false;
|
|
6919
|
+
dispatchReply = "";
|
|
6920
|
+
// the agent's prose since the last dispatch (answeredSinceDispatch)
|
|
6703
6921
|
static DUP_FINAL_MS = 3e3;
|
|
6704
6922
|
lastInterrupted = null;
|
|
6923
|
+
queuedTurn = false;
|
|
6924
|
+
// the current spoken turn is a queued worker utterance (pumpQueue), not a host turn
|
|
6925
|
+
// host-decided overlap (options.overlapDecideMs): pending decision timer, a final that landed while waiting, and
|
|
6926
|
+
// the window in which the overlap the host chose to talk over is absorbed (its growth + its final)
|
|
6927
|
+
decideTimer = null;
|
|
6928
|
+
decideSince = 0;
|
|
6929
|
+
// when this hold began waiting for the host — caps re-decisions (3× overlapDecideMs)
|
|
6930
|
+
decideHeldFinal = "";
|
|
6931
|
+
overlapKeptUntil = 0;
|
|
6932
|
+
overlapKept = "";
|
|
6933
|
+
// what the host judged keep_talking on — only THOSE words are absorbed
|
|
6705
6934
|
// overlap (pause) tier state — AEC + pause-capable sinks only
|
|
6706
6935
|
pausedAt = 0;
|
|
6707
6936
|
lastResumeAt = 0;
|
|
@@ -6750,13 +6979,21 @@ var VoiceEngine = class _VoiceEngine {
|
|
|
6750
6979
|
}
|
|
6751
6980
|
async start() {
|
|
6752
6981
|
this.tts.onAudio = (c) => {
|
|
6753
|
-
if (this.speaking
|
|
6982
|
+
if (!this.speaking && !this.bcActive) return;
|
|
6983
|
+
if (this.wordAnchorPending && this.speaking) this.anchorSubturn();
|
|
6984
|
+
this.player.write(c);
|
|
6754
6985
|
};
|
|
6755
|
-
if (this.options.revealMode === "word") {
|
|
6986
|
+
if (this.options.revealMode === "word" || this.options.heardTimestamps) {
|
|
6756
6987
|
this.tts.wantTimestamps = true;
|
|
6757
|
-
this.tts.onTimestamps = (
|
|
6988
|
+
this.tts.onTimestamps = (words2, start) => {
|
|
6758
6989
|
if (this.interrupted) return;
|
|
6759
|
-
|
|
6990
|
+
start.forEach((s, i) => {
|
|
6991
|
+
if (this.wordAnchorPending) this.wordPending.push([words2[i] ?? "", s]);
|
|
6992
|
+
else {
|
|
6993
|
+
this.wordStarts.push(this.wordOrigin + s);
|
|
6994
|
+
this.wordTexts.push(words2[i] ?? "");
|
|
6995
|
+
}
|
|
6996
|
+
});
|
|
6760
6997
|
};
|
|
6761
6998
|
}
|
|
6762
6999
|
this.stt.onPartial = (text) => this.handlePartial(text);
|
|
@@ -6765,10 +7002,15 @@ var VoiceEngine = class _VoiceEngine {
|
|
|
6765
7002
|
await Promise.all([this.tts.connect(), this.stt.start()]);
|
|
6766
7003
|
this.tts.warmup?.();
|
|
6767
7004
|
this.setState("listening");
|
|
6768
|
-
|
|
7005
|
+
if (this.options.bargeInMode === "aec" && !this.stt.usingAec)
|
|
7006
|
+
log12.warn("bargeInMode=aec but mic does not report echoCancellation applied \u2014 STT may transcribe the assistant");
|
|
7007
|
+
log12.debug(`voice I/O up (${this.usingAec ? "AEC" : "heuristic echo"} capture, mode=${this.options.bargeInMode}, mic reports aec=${this.stt.usingAec})`);
|
|
6769
7008
|
}
|
|
7009
|
+
/** The RESOLVED barge-in tier (the single seam every tier branch reads). `bargeInMode` overrides
|
|
7010
|
+
* the mic's self-report — see the option doc for why the report is not trustworthy on mobile. */
|
|
6770
7011
|
get usingAec() {
|
|
6771
|
-
|
|
7012
|
+
const m = this.options.bargeInMode;
|
|
7013
|
+
return m === "aec" ? true : m === "energy" ? false : this.stt.usingAec;
|
|
6772
7014
|
}
|
|
6773
7015
|
/** Flip barge-in at runtime (e.g. the mic fell back to non-VPIO → go half-duplex so echo can't leak). */
|
|
6774
7016
|
setBargeIn(on) {
|
|
@@ -6800,6 +7042,12 @@ var VoiceEngine = class _VoiceEngine {
|
|
|
6800
7042
|
for (const r of this.idleWaiters.splice(0)) r();
|
|
6801
7043
|
}
|
|
6802
7044
|
}
|
|
7045
|
+
/** True while the assistant is AUDIBLE — deltas flowing or queued audio still draining. Goes false
|
|
7046
|
+
* exactly when `onSpeechEnd` fires (or immediately on barge-in). Lets a host tell "the turn closed
|
|
7047
|
+
* but audio is still playing" from "there was nothing to play", without waiting for a callback. */
|
|
7048
|
+
get audible() {
|
|
7049
|
+
return this.speaking;
|
|
7050
|
+
}
|
|
6803
7051
|
/** Resolve when the engine is no longer speaking (immediate if already idle). */
|
|
6804
7052
|
awaitIdle() {
|
|
6805
7053
|
if (this.state !== "speaking" && this.state !== "thinking") return Promise.resolve();
|
|
@@ -6816,19 +7064,31 @@ var VoiceEngine = class _VoiceEngine {
|
|
|
6816
7064
|
this.drainTimer = null;
|
|
6817
7065
|
}
|
|
6818
7066
|
this.interrupted = false;
|
|
7067
|
+
this.queuedTurn = false;
|
|
6819
7068
|
this.bcSupersede();
|
|
6820
7069
|
this.resetOverlap(true);
|
|
7070
|
+
const acked = ack && this.dispatchRefinal && this.ackSpent;
|
|
7071
|
+
if (acked) {
|
|
7072
|
+
ack = false;
|
|
7073
|
+
this.diag("ack_skipped", { reason: "refinal" });
|
|
7074
|
+
}
|
|
6821
7075
|
this.diag("tts_turn_begin", { ack, gapless: this.speaking });
|
|
7076
|
+
const gapless = this.speaking;
|
|
6822
7077
|
if (!this.speaking) this.player.markTurn();
|
|
6823
7078
|
this.speaking = true;
|
|
6824
7079
|
this.ctxOpen = true;
|
|
6825
7080
|
this.spokeDeltas = false;
|
|
6826
|
-
this.adaptiveAckFired =
|
|
6827
|
-
this.ackProbe = null;
|
|
7081
|
+
this.adaptiveAckFired = acked;
|
|
7082
|
+
this.ackProbe = acked ? "" : null;
|
|
7083
|
+
this.ackPickedFired = false;
|
|
6828
7084
|
this.reply = "";
|
|
6829
7085
|
this.revealText = "";
|
|
6830
7086
|
this.wordStarts = [];
|
|
7087
|
+
this.wordTexts = [];
|
|
7088
|
+
this.ackWords = 0;
|
|
6831
7089
|
this.revealedN = 0;
|
|
7090
|
+
this.resetSubturnAnchor();
|
|
7091
|
+
if (gapless) this.wordAnchorPending = true;
|
|
6832
7092
|
if (this.options.revealMode === "word") this.startWordReveal();
|
|
6833
7093
|
this.emo = this.options.emotions ? new EmotionStream(this.options.showEmotions) : null;
|
|
6834
7094
|
this.echoWords = new Set(this.words(this.prevReply));
|
|
@@ -6840,23 +7100,27 @@ var VoiceEngine = class _VoiceEngine {
|
|
|
6840
7100
|
this.spokeDeltas = true;
|
|
6841
7101
|
this.ackAt = this.clock.now();
|
|
6842
7102
|
this.lastAck = this.options.ackPhrase;
|
|
7103
|
+
this.ackSpent = true;
|
|
7104
|
+
this.ackWords = this.options.ackPhrase.trim().split(/\s+/).length;
|
|
6843
7105
|
for (const w of this.words(this.options.ackPhrase)) this.echoWords.add(w);
|
|
6844
7106
|
} else if (ack && this.options.adaptiveAckMs > 0) {
|
|
7107
|
+
const pick = this.startAckPick();
|
|
7108
|
+
const ms = this.options.adaptiveAckMs;
|
|
6845
7109
|
this.ackTimer = this.clock.setTimeout(() => {
|
|
6846
7110
|
this.ackTimer = null;
|
|
6847
|
-
if (!
|
|
6848
|
-
|
|
6849
|
-
|
|
6850
|
-
|
|
6851
|
-
|
|
6852
|
-
|
|
6853
|
-
|
|
6854
|
-
|
|
6855
|
-
|
|
6856
|
-
|
|
6857
|
-
|
|
6858
|
-
|
|
6859
|
-
|
|
7111
|
+
if (!pick.pending) return this.fireAck(pick.phrase, pick.picked);
|
|
7112
|
+
this.ackTimer = this.clock.setTimeout(() => {
|
|
7113
|
+
this.ackTimer = null;
|
|
7114
|
+
pick.onDone = null;
|
|
7115
|
+
this.ackPick = null;
|
|
7116
|
+
this.diag("ack_cancelled", { reason: "pick_late" });
|
|
7117
|
+
}, ms);
|
|
7118
|
+
pick.onDone = () => {
|
|
7119
|
+
this.clearAckTimer();
|
|
7120
|
+
this.fireAck(pick.phrase, pick.picked);
|
|
7121
|
+
};
|
|
7122
|
+
}, ms);
|
|
7123
|
+
this.ackPick = pick;
|
|
6860
7124
|
}
|
|
6861
7125
|
if (!this.turnStartAt) this.turnStartAt = this.clock.now();
|
|
6862
7126
|
this.setState("thinking");
|
|
@@ -6876,11 +7140,16 @@ var VoiceEngine = class _VoiceEngine {
|
|
|
6876
7140
|
this.adaptiveAckFired = false;
|
|
6877
7141
|
const lead = m ? m[1] : bare;
|
|
6878
7142
|
const norm2 = lead.replace(/[^a-z ]/gi, " ").replace(/\s+/g, " ").trim().toLowerCase();
|
|
7143
|
+
const same = this.ackPickedFired ? bare.match(_VoiceEngine.leadingPhrase(this.lastAck)) : null;
|
|
6879
7144
|
if (_VoiceEngine.ACK_LEADING.test(norm2)) {
|
|
6880
7145
|
const rest = m ? m[2] : "";
|
|
6881
7146
|
this.diag("reflex_ack_suppressed", { lead: lead.trim() });
|
|
6882
7147
|
if (!rest.trim()) return "";
|
|
6883
7148
|
text = rest;
|
|
7149
|
+
} else if (same) {
|
|
7150
|
+
this.diag("reflex_ack_suppressed", { lead: same[0].trim() });
|
|
7151
|
+
text = bare.slice(same[0].length);
|
|
7152
|
+
if (!text.trim()) return "";
|
|
6884
7153
|
} else {
|
|
6885
7154
|
text = probe;
|
|
6886
7155
|
}
|
|
@@ -6893,7 +7162,10 @@ var VoiceEngine = class _VoiceEngine {
|
|
|
6893
7162
|
prose = " " + prose;
|
|
6894
7163
|
}
|
|
6895
7164
|
this.reply += prose;
|
|
6896
|
-
if (prose.trim())
|
|
7165
|
+
if (prose.trim()) {
|
|
7166
|
+
this.repliedSinceDispatch = true;
|
|
7167
|
+
this.dispatchReply += prose;
|
|
7168
|
+
}
|
|
6897
7169
|
for (const w of this.words(this.reply)) this.echoWords.add(w);
|
|
6898
7170
|
this.tts.speak(forSpeech(speech), true);
|
|
6899
7171
|
if (!this.spokeDeltas && this.turnStartAt) log12.debug(`ttft: ${Math.round(this.clock.now() - this.turnStartAt)}ms`);
|
|
@@ -6905,6 +7177,42 @@ var VoiceEngine = class _VoiceEngine {
|
|
|
6905
7177
|
}
|
|
6906
7178
|
return display;
|
|
6907
7179
|
}
|
|
7180
|
+
/** Sub-turn seam: the host's agent stream interrupted the reply with a tool call, so the spoken
|
|
7181
|
+
* turn continues on a FRESH TTS context. Flushes the current context and opens the next, then
|
|
7182
|
+
* re-anchors the karaoke timeline at the position the next context's first audio byte lands on.
|
|
7183
|
+
*
|
|
7184
|
+
* Rotating unconditionally is the point: Cartesia restarts word timestamps at 0 for each context
|
|
7185
|
+
* and ALSO rotates on its own after ~5s idle — so leaving the context alone makes reveal timing
|
|
7186
|
+
* depend on an invisible server-side timer (short gap = one timeline, long gap = a restart we
|
|
7187
|
+
* never saw). By always rotating, the origin is always known and never inferred.
|
|
7188
|
+
*
|
|
7189
|
+
* No-op outside an open turn. Reveal text/word ordering is unchanged — only the time origin. */
|
|
7190
|
+
subturnBoundary() {
|
|
7191
|
+
if (!this.speaking || !this.ctxOpen) return;
|
|
7192
|
+
this.diag("tts_subturn_boundary", { playedMs: Math.round(Math.max(0, this.player.playedMs())) });
|
|
7193
|
+
this.tts.end();
|
|
7194
|
+
this.tts.newContext();
|
|
7195
|
+
this.wordAnchorPending = true;
|
|
7196
|
+
this.wordPending = [];
|
|
7197
|
+
}
|
|
7198
|
+
/** Capture the current sub-turn's time origin = content seconds already written to the sink.
|
|
7199
|
+
* `playedMs + drainMs` is that sum in both regimes (mid-playback it telescopes to the scheduled
|
|
7200
|
+
* content; fully drained, drainMs is 0 and playedMs has reached the scheduled total). */
|
|
7201
|
+
anchorSubturn() {
|
|
7202
|
+
this.wordAnchorPending = false;
|
|
7203
|
+
this.wordOrigin = Math.max(0, this.player.playedMs() + this.player.drainMs()) / 1e3;
|
|
7204
|
+
this.diag("reveal_anchor", { originSec: Number(this.wordOrigin.toFixed(3)), held: this.wordPending.length });
|
|
7205
|
+
for (const [w, s] of this.wordPending) {
|
|
7206
|
+
this.wordStarts.push(this.wordOrigin + s);
|
|
7207
|
+
this.wordTexts.push(w);
|
|
7208
|
+
}
|
|
7209
|
+
this.wordPending = [];
|
|
7210
|
+
}
|
|
7211
|
+
resetSubturnAnchor() {
|
|
7212
|
+
this.wordOrigin = 0;
|
|
7213
|
+
this.wordAnchorPending = false;
|
|
7214
|
+
this.wordPending = [];
|
|
7215
|
+
}
|
|
6908
7216
|
/** Karaoke reveal poll: reveal the display-word prefix up to the count of spoken words whose
|
|
6909
7217
|
* Cartesia start time has been reached on the playback clock (`AudioSink.playedMs`, which excludes
|
|
6910
7218
|
* paused time). Poll-driven (not AudioContext timers) so it's portable across CLI/browser sinks and
|
|
@@ -6919,8 +7227,8 @@ var VoiceEngine = class _VoiceEngine {
|
|
|
6919
7227
|
while (n < this.wordStarts.length && this.wordStarts[n] <= playedSec) n++;
|
|
6920
7228
|
if (n !== this.revealedN) {
|
|
6921
7229
|
this.revealedN = n;
|
|
6922
|
-
const
|
|
6923
|
-
this.options.onReveal(
|
|
7230
|
+
const words2 = this.revealText.trim().split(/\s+/).filter(Boolean);
|
|
7231
|
+
this.options.onReveal(words2.slice(0, n).join(" "));
|
|
6924
7232
|
}
|
|
6925
7233
|
this.revealPoll = this.clock.setTimeout(tick, 60);
|
|
6926
7234
|
};
|
|
@@ -6976,6 +7284,11 @@ var VoiceEngine = class _VoiceEngine {
|
|
|
6976
7284
|
if (this.turnStartAt) log12.debug(`turn: ${Math.round(this.clock.now() - this.turnStartAt)}ms (incl. playback)`);
|
|
6977
7285
|
this.echoUntil = this.clock.now() + 2500;
|
|
6978
7286
|
if (!this.usingAec) this.stt.reset();
|
|
7287
|
+
try {
|
|
7288
|
+
this.options.onSpeechEnd();
|
|
7289
|
+
} catch (e) {
|
|
7290
|
+
log12.warn(`onSpeechEnd threw: ${e instanceof Error ? e.message : e}`);
|
|
7291
|
+
}
|
|
6979
7292
|
this.setState("listening");
|
|
6980
7293
|
if (this.uttQueue.length) this.pumpQueue();
|
|
6981
7294
|
};
|
|
@@ -6990,8 +7303,9 @@ var VoiceEngine = class _VoiceEngine {
|
|
|
6990
7303
|
this.drainTimer = this.clock.setTimeout(drainThenSettle, 15e3);
|
|
6991
7304
|
} else drainThenSettle();
|
|
6992
7305
|
}
|
|
6993
|
-
/**
|
|
6994
|
-
* the user did NOT hear
|
|
7306
|
+
/** What the last barge-in cut — the reply that was playing (and its estimated heard part) plus every queued worker
|
|
7307
|
+
* utterance it dropped — consumed by the host to tell the model what the user did NOT hear, or to park the rest of
|
|
7308
|
+
* a delivery for resuming. Cleared on read. */
|
|
6995
7309
|
takeInterruptedReply() {
|
|
6996
7310
|
const r = this.lastInterrupted;
|
|
6997
7311
|
this.lastInterrupted = null;
|
|
@@ -7000,11 +7314,12 @@ var VoiceEngine = class _VoiceEngine {
|
|
|
7000
7314
|
/** Speak a short filler phrase without starting a model turn (stays in listening mode after). */
|
|
7001
7315
|
speakFiller(text) {
|
|
7002
7316
|
if (!text || this.speaking) return;
|
|
7003
|
-
const replied = this.repliedSinceDispatch;
|
|
7317
|
+
const replied = this.repliedSinceDispatch, reply = this.dispatchReply;
|
|
7004
7318
|
this.beginSpeech();
|
|
7005
7319
|
this.speakDelta(text);
|
|
7006
7320
|
this.endSpeech();
|
|
7007
7321
|
this.repliedSinceDispatch = replied;
|
|
7322
|
+
this.dispatchReply = reply;
|
|
7008
7323
|
}
|
|
7009
7324
|
/** Enqueue a COMPLETE worker utterance (already-split spoken text) onto the central speech queue.
|
|
7010
7325
|
* If nothing is currently speaking it plays immediately; otherwise it queues and plays after the
|
|
@@ -7022,6 +7337,7 @@ var VoiceEngine = class _VoiceEngine {
|
|
|
7022
7337
|
const text = this.uttQueue.shift();
|
|
7023
7338
|
if (text == null) return;
|
|
7024
7339
|
this.beginSpeech();
|
|
7340
|
+
this.queuedTurn = true;
|
|
7025
7341
|
this.speakDelta(text);
|
|
7026
7342
|
this.endSpeech();
|
|
7027
7343
|
}
|
|
@@ -7036,6 +7352,69 @@ var VoiceEngine = class _VoiceEngine {
|
|
|
7036
7352
|
* micro-ack already spoke one. Anchored end-to-end so only a whole ack-only clause matches. */
|
|
7037
7353
|
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))[ ,]*)+$/;
|
|
7038
7354
|
recentAcks = [];
|
|
7355
|
+
/** The armed turn's ack choice: the host's `pickAck` (sync or async); `phrase` undefined = the default pools, drawn
|
|
7356
|
+
* only when the ack actually fires. `onDone` is set only while the ack is due and the pick is still pending. */
|
|
7357
|
+
ackPick = null;
|
|
7358
|
+
startAckPick() {
|
|
7359
|
+
const host = this.options.pickAck;
|
|
7360
|
+
const pick = { pending: false, phrase: void 0, picked: !!host, onDone: null };
|
|
7361
|
+
if (!host) return pick;
|
|
7362
|
+
const ctx = { text: this.lastDispatchText, question: this.lastDispatchWasQuestion, barged: this.bargedBeforeDispatch };
|
|
7363
|
+
const fallback = (e) => {
|
|
7364
|
+
log12.warn(`pickAck failed \u2014 using the default pools: ${e instanceof Error ? e.message : e}`);
|
|
7365
|
+
pick.picked = false;
|
|
7366
|
+
return void 0;
|
|
7367
|
+
};
|
|
7368
|
+
let r;
|
|
7369
|
+
try {
|
|
7370
|
+
r = host(ctx);
|
|
7371
|
+
} catch (e) {
|
|
7372
|
+
pick.phrase = fallback(e);
|
|
7373
|
+
return pick;
|
|
7374
|
+
}
|
|
7375
|
+
if (typeof r?.then !== "function") {
|
|
7376
|
+
pick.phrase = r;
|
|
7377
|
+
return pick;
|
|
7378
|
+
}
|
|
7379
|
+
pick.pending = true;
|
|
7380
|
+
Promise.resolve(r).catch(fallback).then((phrase) => {
|
|
7381
|
+
pick.pending = false;
|
|
7382
|
+
pick.phrase = phrase;
|
|
7383
|
+
if (this.ackPick === pick) pick.onDone?.();
|
|
7384
|
+
});
|
|
7385
|
+
return pick;
|
|
7386
|
+
}
|
|
7387
|
+
/** Speak the adaptive ack (null = the host chose silence, undefined = draw from the default pools) — only if the
|
|
7388
|
+
* turn is still waiting on its reflex. */
|
|
7389
|
+
fireAck(phrase, picked) {
|
|
7390
|
+
this.ackPick = null;
|
|
7391
|
+
if (!this.speaking || !this.ctxOpen || this.spokeDeltas || this.interrupted) return;
|
|
7392
|
+
if (phrase === void 0) phrase = this.pickAck();
|
|
7393
|
+
if (phrase === null || !phrase.trim()) {
|
|
7394
|
+
this.diag("ack_skipped", { reason: "silent" });
|
|
7395
|
+
return;
|
|
7396
|
+
}
|
|
7397
|
+
this.diag("ack_fired", { phrase, adaptive: true, waitedMs: this.options.adaptiveAckMs, ...picked ? { picked } : {} });
|
|
7398
|
+
this.tts.speak(phrase + " ", true);
|
|
7399
|
+
this.spokeDeltas = true;
|
|
7400
|
+
this.ackAt = this.clock.now();
|
|
7401
|
+
this.lastAck = phrase;
|
|
7402
|
+
this.ackSpent = true;
|
|
7403
|
+
this.ackWords = phrase.trim().split(/\s+/).length;
|
|
7404
|
+
for (const w of this.words(phrase)) this.echoWords.add(w);
|
|
7405
|
+
this.setState("speaking");
|
|
7406
|
+
this.adaptiveAckFired = true;
|
|
7407
|
+
this.ackProbe = "";
|
|
7408
|
+
this.ackPickedFired = picked;
|
|
7409
|
+
this.options.onAdaptiveAck();
|
|
7410
|
+
}
|
|
7411
|
+
/** `phrase`'s words at the very start of a text, any punctuation between them ("Oh no." ~ "oh, no —"). */
|
|
7412
|
+
static leadingPhrase(phrase) {
|
|
7413
|
+
const w = phrase.toLowerCase().match(/[a-z0-9]+/g) ?? [];
|
|
7414
|
+
return w.length ? new RegExp(`^[^a-z0-9]*${w.join("[^a-z0-9]+")}(?![a-z0-9])[^a-z0-9]*`, "i") : /$^/;
|
|
7415
|
+
}
|
|
7416
|
+
ackPickedFired = false;
|
|
7417
|
+
// the fired ack was the host's pick → the probe also strips the reflex repeating it
|
|
7039
7418
|
pickAck() {
|
|
7040
7419
|
const pool = this.lastDispatchWasQuestion ? _VoiceEngine.ACKS_QUESTION : _VoiceEngine.ACKS_NEUTRAL;
|
|
7041
7420
|
const fresh = pool.filter((p) => !this.recentAcks.includes(p));
|
|
@@ -7045,7 +7424,16 @@ var VoiceEngine = class _VoiceEngine {
|
|
|
7045
7424
|
return phrase;
|
|
7046
7425
|
}
|
|
7047
7426
|
lastDispatchWasQuestion = false;
|
|
7427
|
+
dispatchRefinal = false;
|
|
7428
|
+
// the last dispatch re-finalized (superseded) the one before it — same logical turn
|
|
7429
|
+
ackSpent = false;
|
|
7430
|
+
// an ack already played for the current logical turn (reset by a non-refinal dispatch)
|
|
7431
|
+
bargedSinceDispatch = false;
|
|
7432
|
+
// an effective interrupt landed since the last dispatch
|
|
7433
|
+
bargedBeforeDispatch = false;
|
|
7434
|
+
// …as of the last dispatch (AckContext.barged)
|
|
7048
7435
|
clearAckTimer(reason) {
|
|
7436
|
+
if (reason) this.ackPick = null;
|
|
7049
7437
|
if (!this.ackTimer) return;
|
|
7050
7438
|
this.clock.clearTimeout(this.ackTimer);
|
|
7051
7439
|
this.ackTimer = null;
|
|
@@ -7061,11 +7449,16 @@ var VoiceEngine = class _VoiceEngine {
|
|
|
7061
7449
|
this.clearAckTimer("interrupt");
|
|
7062
7450
|
this.adaptiveAckFired = false;
|
|
7063
7451
|
this.ackProbe = null;
|
|
7064
|
-
|
|
7065
|
-
const droppedQueued =
|
|
7066
|
-
|
|
7067
|
-
|
|
7452
|
+
const queued = this.uttQueue.splice(0);
|
|
7453
|
+
const droppedQueued = queued.length;
|
|
7454
|
+
if (droppedQueued) log12.info(`barge-in cut ${droppedQueued} queued worker utterance(s) \u2014 handed to the host`);
|
|
7455
|
+
this.overlapKeptUntil = 0;
|
|
7456
|
+
if (!this.speaking && !this.drainTimer) {
|
|
7457
|
+
if (droppedQueued) this.lastInterrupted = { full: "", heard: "", queued, queuedTurn: false };
|
|
7458
|
+
return;
|
|
7459
|
+
}
|
|
7068
7460
|
this.stopWordReveal(false);
|
|
7461
|
+
this.resetSubturnAnchor();
|
|
7069
7462
|
this.diag("interrupt", { droppedQueued, ctxOpen: this.ctxOpen, playedMs: Math.round(Math.max(0, this.player.playedMs())) });
|
|
7070
7463
|
if (this.drainTimer) {
|
|
7071
7464
|
this.clock.clearTimeout(this.drainTimer);
|
|
@@ -7073,8 +7466,9 @@ var VoiceEngine = class _VoiceEngine {
|
|
|
7073
7466
|
}
|
|
7074
7467
|
this.resetOverlap(false);
|
|
7075
7468
|
this.lastResumeAt = 0;
|
|
7076
|
-
const
|
|
7077
|
-
if (this.reply) this.lastInterrupted = { full: this.reply, heard: this.
|
|
7469
|
+
const { heard, exact } = this.heardCut();
|
|
7470
|
+
if (this.reply || droppedQueued) this.lastInterrupted = { full: this.reply, heard, exact, queued, queuedTurn: this.queuedTurn };
|
|
7471
|
+
if (heard.length < this.reply.length || droppedQueued) this.bargedSinceDispatch = true;
|
|
7078
7472
|
this.speaking = false;
|
|
7079
7473
|
this.ctxOpen = false;
|
|
7080
7474
|
this.interrupted = true;
|
|
@@ -7086,8 +7480,23 @@ var VoiceEngine = class _VoiceEngine {
|
|
|
7086
7480
|
if (this.reply) this.prevReply = this.reply;
|
|
7087
7481
|
this.setState("listening");
|
|
7088
7482
|
}
|
|
7483
|
+
/** How much of the reply the user heard at a cut (paused time is excluded by the sink's playedMs). Exact from the
|
|
7484
|
+
* TTS word timestamps the playback clock has reached (the ack's leading words skipped); without timestamps, an
|
|
7485
|
+
* estimate — played seconds × ~15 chars/s of TTS speech, coarse and often mid-word. */
|
|
7486
|
+
heardCut() {
|
|
7487
|
+
const played = Math.max(0, this.player.playedMs());
|
|
7488
|
+
if (!this.wordTexts.length) return { heard: this.reply.slice(0, Math.round(played / 1e3 * 15)), exact: false };
|
|
7489
|
+
let n = 0;
|
|
7490
|
+
while (n < this.wordStarts.length && this.wordStarts[n] <= played / 1e3) n++;
|
|
7491
|
+
if (!this.ctxOpen && n === this.wordStarts.length && played / 1e3 - this.wordStarts[n - 1] >= 0.4) return { heard: this.reply, exact: true };
|
|
7492
|
+
return { heard: this.reply.slice(0, heardPrefix(this.reply, this.wordTexts.slice(this.ackWords, n))), exact: true };
|
|
7493
|
+
}
|
|
7089
7494
|
stop() {
|
|
7090
7495
|
this.uttQueue = [];
|
|
7496
|
+
if (this.decideTimer) {
|
|
7497
|
+
this.clock.clearTimeout(this.decideTimer);
|
|
7498
|
+
this.decideTimer = null;
|
|
7499
|
+
}
|
|
7091
7500
|
this.clearAckTimer();
|
|
7092
7501
|
if (this.resumeTimer) this.clock.clearTimeout(this.resumeTimer);
|
|
7093
7502
|
if (this.pendingTimer) this.clock.clearTimeout(this.pendingTimer);
|
|
@@ -7100,9 +7509,41 @@ var VoiceEngine = class _VoiceEngine {
|
|
|
7100
7509
|
this.setState("idle");
|
|
7101
7510
|
}
|
|
7102
7511
|
// --- listening side (STT-driven) ---
|
|
7512
|
+
/** Echo-comparable tokens. Our text and the STT's rendering of it must tokenize alike: every non-alphanumeric
|
|
7513
|
+
* (em-dash, non-breaking hyphen, nbsp, …) SEPARATES words — deleting it glued "releases—GPT‑6" into
|
|
7514
|
+
* "releasesgpt6" and the echo gate missed our own words. Numbers take the spoken form STT may hear: letter/digit
|
|
7515
|
+
* runs split ("K2.8" → k 2 point 8), a decimal point is "point", number words are digits ("five" → 5).
|
|
7516
|
+
* Apostrophes are dropped (it's → its). Words under 2 letters are noise; digits are kept. Any script counts (Unicode
|
|
7517
|
+
* letters/marks/digits): an ASCII-only split gave Hebrew speech ZERO words, which the echo gate read as pure echo
|
|
7518
|
+
* (live 20260923-125143: "סבתא מחוץ לדלת?" and "תודה." swallowed). */
|
|
7103
7519
|
words(s) {
|
|
7104
|
-
return s.toLowerCase().replace(/[
|
|
7105
|
-
}
|
|
7520
|
+
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));
|
|
7521
|
+
}
|
|
7522
|
+
static NUMBER_WORDS = Object.fromEntries(
|
|
7523
|
+
[
|
|
7524
|
+
"zero",
|
|
7525
|
+
"one",
|
|
7526
|
+
"two",
|
|
7527
|
+
"three",
|
|
7528
|
+
"four",
|
|
7529
|
+
"five",
|
|
7530
|
+
"six",
|
|
7531
|
+
"seven",
|
|
7532
|
+
"eight",
|
|
7533
|
+
"nine",
|
|
7534
|
+
"ten",
|
|
7535
|
+
"eleven",
|
|
7536
|
+
"twelve",
|
|
7537
|
+
"thirteen",
|
|
7538
|
+
"fourteen",
|
|
7539
|
+
"fifteen",
|
|
7540
|
+
"sixteen",
|
|
7541
|
+
"seventeen",
|
|
7542
|
+
"eighteen",
|
|
7543
|
+
"nineteen",
|
|
7544
|
+
"twenty"
|
|
7545
|
+
].map((w, i) => [w, String(i)])
|
|
7546
|
+
);
|
|
7106
7547
|
novelWords(text) {
|
|
7107
7548
|
return this.words(text).filter((w) => !this.echoWords.has(w));
|
|
7108
7549
|
}
|
|
@@ -7119,6 +7560,22 @@ var VoiceEngine = class _VoiceEngine {
|
|
|
7119
7560
|
const novel = this.novelWords(text).length;
|
|
7120
7561
|
return novel > 0 && novel / Math.max(1, total) > 0.5;
|
|
7121
7562
|
}
|
|
7563
|
+
/** Echo verdict for a FINAL in the echo window (AEC tier). Bag-of-words genuine() alone swallowed real replies
|
|
7564
|
+
* that reuse a few of our words — live 20260923-102258, right after a barge over "…Gemini 3.8 Flash…":
|
|
7565
|
+
* "Wait, there is a new Gemini model?" (3 novel / 6) was dropped twice and the user had to repeat it. Echo
|
|
7566
|
+
* of our own audio reproduces RUNS of what we said, in order (mis-hearings break a word, not the run); a
|
|
7567
|
+
* reply that shares vocabulary scatters it. So a ≥4-word final that fails the bag test is echo only if at
|
|
7568
|
+
* least half its words sit in a word pair we actually said. Shorter finals can't show order → bag rule. */
|
|
7569
|
+
echoFinal(text) {
|
|
7570
|
+
if (this.genuine(text)) return false;
|
|
7571
|
+
const w = this.words(text);
|
|
7572
|
+
if (w.length < 4) return true;
|
|
7573
|
+
const said = this.words(`${this.prevReply} ${this.reply} ${this.lastAck}`);
|
|
7574
|
+
const pairs = new Set(said.slice(1).map((x, i) => `${said[i]} ${x}`));
|
|
7575
|
+
const inRun = w.map(() => false);
|
|
7576
|
+
for (let i = 1; i < w.length; i++) if (pairs.has(`${w[i - 1]} ${w[i]}`)) inRun[i - 1] = inRun[i] = true;
|
|
7577
|
+
return inRun.filter(Boolean).length * 2 >= w.length;
|
|
7578
|
+
}
|
|
7122
7579
|
handlePartial(text) {
|
|
7123
7580
|
if (this.speaking) {
|
|
7124
7581
|
if (!this.options.bargeIn) return;
|
|
@@ -7130,14 +7587,29 @@ var VoiceEngine = class _VoiceEngine {
|
|
|
7130
7587
|
if (!this.echoActive() || (this.usingAec ? this.genuine(text) : this.novelWords(text).length >= 1)) this.options.onPartial(text);
|
|
7131
7588
|
return;
|
|
7132
7589
|
}
|
|
7590
|
+
if (this.echoesDispatch(text, true)) {
|
|
7591
|
+
if (this.lastDupPartial !== text.trim()) {
|
|
7592
|
+
this.lastDupPartial = text.trim();
|
|
7593
|
+
this.diag("dup_partial", { reason: "dup_final", text: text.slice(0, 60) });
|
|
7594
|
+
}
|
|
7595
|
+
if (this.pausedAt) {
|
|
7596
|
+
this.diag("overlap_resume", { heldMs: Math.round(this.clock.now() - this.pausedAt), reason: "dup_final" });
|
|
7597
|
+
this.resetOverlap(true);
|
|
7598
|
+
}
|
|
7599
|
+
return;
|
|
7600
|
+
}
|
|
7133
7601
|
if (this.overlapCapable) {
|
|
7134
7602
|
const txt = text.trim();
|
|
7135
7603
|
if (!txt || txt === this.lastOverlapPartial) return;
|
|
7136
7604
|
this.lastOverlapPartial = txt;
|
|
7137
7605
|
if (!this.genuine(txt)) {
|
|
7138
|
-
if (this.pausedAt) this.armResume();
|
|
7606
|
+
if (this.pausedAt && !this.decideTimer) this.armResume();
|
|
7139
7607
|
return;
|
|
7140
7608
|
}
|
|
7609
|
+
if (this.overlapKeptUntil && this.clock.now() < this.overlapKeptUntil) {
|
|
7610
|
+
if (!this.unjudged(txt)) return;
|
|
7611
|
+
this.overlapKeptUntil = 0;
|
|
7612
|
+
}
|
|
7141
7613
|
if (!this.pausedAt) {
|
|
7142
7614
|
this.pausedAt = this.clock.now();
|
|
7143
7615
|
const sinceResumeMs = this.lastResumeAt ? Math.round(this.clock.now() - this.lastResumeAt) : void 0;
|
|
@@ -7152,13 +7624,14 @@ var VoiceEngine = class _VoiceEngine {
|
|
|
7152
7624
|
}
|
|
7153
7625
|
}
|
|
7154
7626
|
if (this.words(txt).length >= 2) {
|
|
7155
|
-
|
|
7156
|
-
|
|
7157
|
-
|
|
7158
|
-
|
|
7627
|
+
if (this.options.overlapDecideMs > 0 && this.shortOverlap(txt)) {
|
|
7628
|
+
this.awaitOverlapDecision(txt);
|
|
7629
|
+
return;
|
|
7630
|
+
}
|
|
7631
|
+
this.cedeOverlap("overlap_cede", txt);
|
|
7159
7632
|
return;
|
|
7160
7633
|
}
|
|
7161
|
-
this.armResume();
|
|
7634
|
+
if (!this.decideTimer) this.armResume();
|
|
7162
7635
|
return;
|
|
7163
7636
|
}
|
|
7164
7637
|
const barge = this.usingAec ? this.genuine(text) : this.novelWords(text).length >= (this.suspectUntil ? 1 : 2);
|
|
@@ -7203,7 +7676,7 @@ var VoiceEngine = class _VoiceEngine {
|
|
|
7203
7676
|
this.specTimer = null;
|
|
7204
7677
|
if (this.speaking || this.specSpent || this.pendingUtt) return;
|
|
7205
7678
|
if (this.words(this.specPartial).length < this.options.speculativeMinWords) return;
|
|
7206
|
-
const flat = this.specPartial
|
|
7679
|
+
const flat = normWord(this.specPartial);
|
|
7207
7680
|
if (flat && this.lastDispatchFlat && this.clock.now() - this.lastDispatchAt < _VoiceEngine.DUP_FINAL_MS && (this.lastDispatchFlat.startsWith(flat) || flat.startsWith(this.lastDispatchFlat))) return;
|
|
7208
7681
|
this.specSpent = true;
|
|
7209
7682
|
this.specText = this.specPartial;
|
|
@@ -7242,7 +7715,7 @@ var VoiceEngine = class _VoiceEngine {
|
|
|
7242
7715
|
const letters = p.replace(/[^\p{L}]/gu, "");
|
|
7243
7716
|
const latin = p.replace(/[^A-Za-z]/g, "");
|
|
7244
7717
|
if (!letters || latin.length / letters.length < 0.7) return veto("non_latin");
|
|
7245
|
-
const flat = p
|
|
7718
|
+
const flat = normWord(p);
|
|
7246
7719
|
if (flat && this.lastDispatchFlat && this.clock.now() - this.lastDispatchAt < _VoiceEngine.DUP_FINAL_MS && (this.lastDispatchFlat.startsWith(flat) || flat.startsWith(this.lastDispatchFlat))) return;
|
|
7247
7720
|
this.fireBackchannel();
|
|
7248
7721
|
}, this.options.backchannelMs);
|
|
@@ -7330,6 +7803,10 @@ var VoiceEngine = class _VoiceEngine {
|
|
|
7330
7803
|
}
|
|
7331
7804
|
if (pfx && norm2(nw[pw.length - 1]).startsWith(norm2(pw[pw.length - 1]))) return next;
|
|
7332
7805
|
}
|
|
7806
|
+
if (_VoiceEngine.restates(prev, next)) {
|
|
7807
|
+
this.diag("merge_restated", { dropped: prev.slice(0, 60) });
|
|
7808
|
+
return next;
|
|
7809
|
+
}
|
|
7333
7810
|
const max = Math.min(pw.length, nw.length);
|
|
7334
7811
|
for (let k = max; k > 0; k--) {
|
|
7335
7812
|
let match = true;
|
|
@@ -7344,6 +7821,25 @@ var VoiceEngine = class _VoiceEngine {
|
|
|
7344
7821
|
static normWord(w) {
|
|
7345
7822
|
return normWord(w);
|
|
7346
7823
|
}
|
|
7824
|
+
/** `next` RESTATES the held, unfinished `prev` — the user restarted ("Wo- wait, can you explain more about" →
|
|
7825
|
+
* "What— wait, can you explain more about that? …", live 20260924-093945, where both landed in the dispatch).
|
|
7826
|
+
* Keep only the later version. Conservative: prev's words from index i ≤ 2 through its END reappear contiguously
|
|
7827
|
+
* 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
|
|
7828
|
+
* an overlapping re-final continuing prev ("Tell me about the" + "about the weather"), deduped by the caller
|
|
7829
|
+
* keeping prev's head. prev's last word may be a char-prefix (a mid-word endpoint). */
|
|
7830
|
+
static restates(prev, next) {
|
|
7831
|
+
const p = prev.split(/\s+/).map(normWord).filter(Boolean), n = next.split(/\s+/).map(normWord).filter(Boolean);
|
|
7832
|
+
for (let i = 0; i <= 2; i++) {
|
|
7833
|
+
const k = p.length - i;
|
|
7834
|
+
if (k < 4 || k < 0.75 * p.length) return false;
|
|
7835
|
+
for (let j = i === 0 ? 0 : 1; j <= 2 && j + k <= n.length; j++) {
|
|
7836
|
+
let ok = true;
|
|
7837
|
+
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];
|
|
7838
|
+
if (ok) return true;
|
|
7839
|
+
}
|
|
7840
|
+
}
|
|
7841
|
+
return false;
|
|
7842
|
+
}
|
|
7347
7843
|
/** Soniox re-finalization of an ALREADY-DISPATCHED utterance, arriving PAST the merge window:
|
|
7348
7844
|
* the new final is a strict word-prefix superset of the last dispatch (the last dispatched word
|
|
7349
7845
|
* may be a char-prefix of the corresponding new word — a mid-word endpoint), and it lands within
|
|
@@ -7374,18 +7870,71 @@ var VoiceEngine = class _VoiceEngine {
|
|
|
7374
7870
|
if (!nSame.startsWith(pLast)) return false;
|
|
7375
7871
|
return nw.length > pw.length || nSame.length > pLast.length;
|
|
7376
7872
|
}
|
|
7873
|
+
/** The STT re-emitting (part of) the utterance just dispatched: ≥2 words that form a contiguous run of the dispatched
|
|
7874
|
+
* words, within DUP_FINAL_MS. A merged dispatch ("Okay." + "Um, check for the recent news in AI.") has its later
|
|
7875
|
+
* segment come back as a partial/final a beat later — the identical/superset guards miss a PIECE. A human cannot
|
|
7876
|
+
* re-speak a ≥2-word stretch of what they said within ~1s; one word ("stop") is never treated as a duplicate.
|
|
7877
|
+
* `partial`: the last word may be cut mid-word ("…news in A"). */
|
|
7878
|
+
echoesDispatch(text, partial = false) {
|
|
7879
|
+
if (!this.lastDispatchWords.length || this.clock.now() - this.lastDispatchAt >= _VoiceEngine.DUP_FINAL_MS) return false;
|
|
7880
|
+
const d = this.lastDispatchWords.map(normWord).filter(Boolean), w = text.trim().split(/\s+/).map(normWord).filter(Boolean);
|
|
7881
|
+
if (w.length < 2 || w.length > d.length) return false;
|
|
7882
|
+
const last = w.length - 1;
|
|
7883
|
+
outer: for (let i = 0; i + w.length <= d.length; i++) {
|
|
7884
|
+
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;
|
|
7885
|
+
return true;
|
|
7886
|
+
}
|
|
7887
|
+
return false;
|
|
7888
|
+
}
|
|
7889
|
+
/** The agent replied since the dispatch with more than an acknowledgement (ACK_LEADING: "On it.", "Sure, one sec."). */
|
|
7890
|
+
answeredSinceDispatch() {
|
|
7891
|
+
return this.repliedSinceDispatch && !_VoiceEngine.ACK_LEADING.test(this.dispatchReply.replace(/[^a-z ]/gi, " ").replace(/\s+/g, " ").trim().toLowerCase());
|
|
7892
|
+
}
|
|
7893
|
+
lastDupPartial = "";
|
|
7894
|
+
// dup_partial diag once per distinct partial
|
|
7377
7895
|
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;
|
|
7896
|
+
/** The user's line right after a barge cut our speech (or a re-final growing it), with no terminal punctuation:
|
|
7897
|
+
* they took the floor mid-thought — Soniox closes on the pause, not the thought (live 20260923-192543: "But isn't it"
|
|
7898
|
+
* → "…can be done" → "…with boxing" → "…or semantic?" dispatched 4 times). Held like an incomplete utterance. */
|
|
7899
|
+
unfinishedBargeLine(text) {
|
|
7900
|
+
const line = this.bargedSinceDispatch || this.bargedBeforeDispatch && this.refinalizes(text);
|
|
7901
|
+
return line && !/[.?!…]["'”’)\]]*$/.test(text.trim()) && !_VoiceEngine.CONTROL_RE.test(text.trim());
|
|
7902
|
+
}
|
|
7903
|
+
/** A short line that is complete on its own ("Stop", "Yes", "Okay, stop", "go on") — never held as unfinished. */
|
|
7904
|
+
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)");
|
|
7378
7905
|
/** The utterance sounds like the user paused mid-thought (trailing conjunction/filler/comma). */
|
|
7379
7906
|
looksIncomplete(text) {
|
|
7380
|
-
return _VoiceEngine.TRAIL_RE.test(text.trim());
|
|
7907
|
+
return _VoiceEngine.TRAIL_RE.test(text.trim()) && !_VoiceEngine.CONTROL_RE.test(text.trim());
|
|
7381
7908
|
}
|
|
7382
7909
|
handleUtterance(text) {
|
|
7383
|
-
if (this.speaking &&
|
|
7910
|
+
if (this.speaking && this.echoesDispatch(text)) {
|
|
7911
|
+
this.diag("utterance_drop", { reason: "dup_final", text: text.slice(0, 60) });
|
|
7912
|
+
if (this.pausedAt) {
|
|
7913
|
+
this.diag("overlap_resume", { heldMs: Math.round(this.clock.now() - this.pausedAt), reason: "dup_final" });
|
|
7914
|
+
this.resetOverlap(true);
|
|
7915
|
+
}
|
|
7916
|
+
return;
|
|
7917
|
+
}
|
|
7918
|
+
if (this.decideTimer) {
|
|
7919
|
+
this.decideHeldFinal = this.mergeUtterance(this.decideHeldFinal, text);
|
|
7920
|
+
this.diag("utterance_held", { reason: "overlap_decide", text: text.slice(0, 60) });
|
|
7921
|
+
return;
|
|
7922
|
+
}
|
|
7923
|
+
if (this.overlapKeptUntil) {
|
|
7924
|
+
const kept = this.clock.now() < this.overlapKeptUntil && !this.unjudged(text);
|
|
7925
|
+
this.overlapKeptUntil = 0;
|
|
7926
|
+
if (kept) {
|
|
7927
|
+
this.diag("utterance_drop", { reason: "overlap_kept", text: text.slice(0, 60) });
|
|
7928
|
+
return;
|
|
7929
|
+
}
|
|
7930
|
+
}
|
|
7931
|
+
const ownTail = !this.pausedAt && (this.clock.now() < this.bargeGraceUntil || this.refinalizes(text));
|
|
7932
|
+
if (this.speaking && (this.ctxOpen || this.pausedAt) && this.overlapCapable && !ownTail) {
|
|
7384
7933
|
this.diag("utterance_drop", { reason: "overlap_noise", text: text.slice(0, 60) });
|
|
7385
7934
|
this.stt.reset();
|
|
7386
7935
|
return;
|
|
7387
7936
|
}
|
|
7388
|
-
if (this.echoActive() && (!this.options.bargeIn || (this.usingAec ?
|
|
7937
|
+
if (this.echoActive() && (!this.options.bargeIn || (this.usingAec ? this.echoFinal(text) : this.novelWords(text).length < 2))) {
|
|
7389
7938
|
this.diag("echo_swallow", { guard: "echo_gate", text: text.slice(0, 60) });
|
|
7390
7939
|
this.stt.reset();
|
|
7391
7940
|
return;
|
|
@@ -7399,9 +7948,28 @@ var VoiceEngine = class _VoiceEngine {
|
|
|
7399
7948
|
if (this.pendingUtt) this.mergePath = "merged";
|
|
7400
7949
|
this.pendingUtt = this.mergeUtterance(this.pendingUtt, text);
|
|
7401
7950
|
if (this.pendingTimer) this.clock.clearTimeout(this.pendingTimer);
|
|
7402
|
-
if (this.options.
|
|
7951
|
+
if (this.options.adaptiveEndpointing) {
|
|
7952
|
+
const holdMs = computeHoldMs({
|
|
7953
|
+
transcript: this.pendingUtt,
|
|
7954
|
+
lastAiMessage: this.prevReply,
|
|
7955
|
+
baseMs: this.options.utteranceMergeMs,
|
|
7956
|
+
incompleteMs: this.options.incompleteMergeMs
|
|
7957
|
+
});
|
|
7958
|
+
const tier = endpointTier(this.pendingUtt);
|
|
7959
|
+
this.holdTier = tier;
|
|
7960
|
+
this.diag("hold", { reason: "adaptive", tier, punctuated: tier === 3, holdMs, tail: this.pendingUtt.slice(-40) });
|
|
7961
|
+
if (holdMs > this.options.utteranceMergeMs) {
|
|
7962
|
+
this.options.onHold();
|
|
7963
|
+
if (tier === 1 && this.options.holdFiller && !this.speaking) this.speakFiller(this.options.holdFiller);
|
|
7964
|
+
}
|
|
7965
|
+
if (holdMs <= 0) return this.flushUtterance();
|
|
7966
|
+
this.pendingTimer = this.clock.setTimeout(() => this.flushUtterance(), holdMs);
|
|
7967
|
+
return;
|
|
7968
|
+
}
|
|
7969
|
+
const bargeHold = !this.looksIncomplete(this.pendingUtt) && this.unfinishedBargeLine(this.pendingUtt);
|
|
7970
|
+
if (this.options.incompleteMergeMs && (bargeHold || this.looksIncomplete(this.pendingUtt))) {
|
|
7403
7971
|
log12.verbose(`hold: incomplete utterance "${this.pendingUtt.slice(-40)}"`);
|
|
7404
|
-
this.diag("hold", { reason: "incomplete", tail: this.pendingUtt.slice(-40) });
|
|
7972
|
+
this.diag("hold", { reason: bargeHold ? "barge_unfinished" : "incomplete", tail: this.pendingUtt.slice(-40) });
|
|
7405
7973
|
this.options.onHold();
|
|
7406
7974
|
if (this.options.holdFiller && !this.speaking) this.speakFiller(this.options.holdFiller);
|
|
7407
7975
|
this.pendingTimer = this.clock.setTimeout(() => this.flushUtterance(), this.options.incompleteMergeMs);
|
|
@@ -7432,9 +8000,13 @@ var VoiceEngine = class _VoiceEngine {
|
|
|
7432
8000
|
this.pendingUtt = "";
|
|
7433
8001
|
const path = this.mergePath;
|
|
7434
8002
|
this.mergePath = "direct";
|
|
8003
|
+
const heldTier = this.holdTier;
|
|
8004
|
+
this.holdTier = 0;
|
|
7435
8005
|
if (text) {
|
|
7436
|
-
const flat = text
|
|
7437
|
-
|
|
8006
|
+
const flat = normWord(text);
|
|
8007
|
+
const answered = this.answeredSinceDispatch();
|
|
8008
|
+
const part = flat !== this.lastDispatchFlat && !answered && this.echoesDispatch(text);
|
|
8009
|
+
if (part || flat && flat === this.lastDispatchFlat && !answered && this.clock.now() - this.lastDispatchAt < _VoiceEngine.DUP_FINAL_MS) {
|
|
7438
8010
|
log12.verbose(`dropped duplicate final "${text.slice(0, 40)}" (identical to the just-dispatched utterance, no reply between)`);
|
|
7439
8011
|
this.diag("utterance_drop", { reason: "dup_final", text: text.slice(0, 60) });
|
|
7440
8012
|
return;
|
|
@@ -7445,22 +8017,121 @@ var VoiceEngine = class _VoiceEngine {
|
|
|
7445
8017
|
this.diag("utterance_drop", { reason: "refinalized", text: text.slice(0, 60) });
|
|
7446
8018
|
this.lastDispatchFlat = flat;
|
|
7447
8019
|
this.lastDispatchWords = text.trim().split(/\s+/);
|
|
8020
|
+
this.lastDispatchText = text;
|
|
7448
8021
|
return;
|
|
7449
8022
|
}
|
|
8023
|
+
const meta = refinal ? { supersedes: this.lastDispatchText } : void 0;
|
|
7450
8024
|
this.lastDispatchFlat = flat;
|
|
7451
8025
|
this.lastDispatchWords = text.trim().split(/\s+/);
|
|
8026
|
+
this.lastDispatchText = text;
|
|
7452
8027
|
this.lastDispatchAt = this.clock.now();
|
|
7453
8028
|
this.repliedSinceDispatch = false;
|
|
8029
|
+
this.dispatchReply = "";
|
|
7454
8030
|
this.lastDispatchWasQuestion = /\?\s*$/.test(text);
|
|
8031
|
+
this.bargedBeforeDispatch = refinal ? this.bargedBeforeDispatch || this.bargedSinceDispatch : this.bargedSinceDispatch;
|
|
8032
|
+
this.bargedSinceDispatch = false;
|
|
8033
|
+
this.dispatchRefinal = refinal;
|
|
8034
|
+
if (!refinal) this.ackSpent = false;
|
|
7455
8035
|
this.turnStartAt = this.clock.now();
|
|
7456
8036
|
this.bargeGraceUntil = this.clock.now() + this.options.bargeGraceMs;
|
|
7457
|
-
this.diag("dispatch", { text, path: refinal ? "refinalized" : path, question: this.lastDispatchWasQuestion });
|
|
7458
|
-
this.options.onUtterance(text);
|
|
8037
|
+
this.diag("dispatch", { text, path: refinal ? "refinalized" : path, heldTier, question: this.lastDispatchWasQuestion, ...this.bargedBeforeDispatch ? { barged: true } : {} });
|
|
8038
|
+
this.options.onUtterance(text, meta);
|
|
7459
8039
|
}
|
|
7460
8040
|
}
|
|
7461
8041
|
get overlapCapable() {
|
|
7462
8042
|
return this.usingAec && this.options.overlapPause && !!this.player.pause && !!this.player.resume;
|
|
7463
8043
|
}
|
|
8044
|
+
/** Stop/question words that make an overlap an obvious barge — never held for a host decision. */
|
|
8045
|
+
static OVERLAP_CEDE_WORDS = /* @__PURE__ */ new Set([
|
|
8046
|
+
"stop",
|
|
8047
|
+
"wait",
|
|
8048
|
+
"no",
|
|
8049
|
+
"hold",
|
|
8050
|
+
"hang",
|
|
8051
|
+
"pause",
|
|
8052
|
+
"quiet",
|
|
8053
|
+
"enough",
|
|
8054
|
+
"shut",
|
|
8055
|
+
"cancel",
|
|
8056
|
+
"what",
|
|
8057
|
+
"why",
|
|
8058
|
+
"how",
|
|
8059
|
+
"who",
|
|
8060
|
+
"when",
|
|
8061
|
+
"where",
|
|
8062
|
+
"which"
|
|
8063
|
+
]);
|
|
8064
|
+
/** A short, non-urgent overlap: the kind a listener cue or reaction takes (see overlapDecideMs). */
|
|
8065
|
+
shortOverlap(text) {
|
|
8066
|
+
const w = this.words(text);
|
|
8067
|
+
return w.length <= this.options.overlapDecideMaxWords && !/\?/.test(text) && !w.some((x) => _VoiceEngine.OVERLAP_CEDE_WORDS.has(x));
|
|
8068
|
+
}
|
|
8069
|
+
/** Cede the floor to an overlap: interrupt, tell the host, then dispatch a final that was held while deciding. */
|
|
8070
|
+
cedeOverlap(trigger, txt) {
|
|
8071
|
+
const phase = this.ctxOpen ? "speaking" : "drain";
|
|
8072
|
+
const held2 = this.decideHeldFinal;
|
|
8073
|
+
this.decideHeldFinal = "";
|
|
8074
|
+
this.diag("barge_in", { phase, trigger, novel: this.novelWords(txt).length, total: this.words(txt).length });
|
|
8075
|
+
this.interrupt();
|
|
8076
|
+
this.options.onBargeIn(phase);
|
|
8077
|
+
if (held2) this.handleUtterance(held2);
|
|
8078
|
+
}
|
|
8079
|
+
/** Wait (bounded) for the host's verdict. `fresh` = a re-decision on grown text: it gets its own window — the host
|
|
8080
|
+
* was only just asked about it (live: "Oh," → "Oh, that" → "Oh, that— that's new" outgrew every verdict, and the
|
|
8081
|
+
* first window ran out mid-chase, ceding a reaction Jev kept) — but the whole hold is capped at 3× the window. */
|
|
8082
|
+
awaitOverlapDecision(txt, fresh = false) {
|
|
8083
|
+
if (this.decideTimer && !fresh) return;
|
|
8084
|
+
if (this.resumeTimer) {
|
|
8085
|
+
this.clock.clearTimeout(this.resumeTimer);
|
|
8086
|
+
this.resumeTimer = null;
|
|
8087
|
+
}
|
|
8088
|
+
if (this.decideTimer) this.clock.clearTimeout(this.decideTimer);
|
|
8089
|
+
const now4 = this.clock.now(), ms = this.options.overlapDecideMs;
|
|
8090
|
+
if (!this.decideSince) this.decideSince = now4;
|
|
8091
|
+
const wait = Math.min(ms, this.decideSince + 3 * ms - now4);
|
|
8092
|
+
this.diag("overlap_await", { text: txt.slice(0, 60), ms: wait });
|
|
8093
|
+
this.decideTimer = this.clock.setTimeout(() => {
|
|
8094
|
+
this.decideTimer = null;
|
|
8095
|
+
if (this.pausedAt) this.cedeOverlap("overlap_decide_timeout", this.lastOverlapPartial || txt);
|
|
8096
|
+
}, Math.max(0, wait));
|
|
8097
|
+
}
|
|
8098
|
+
/** Words in `text` the host's keep_talking verdict never covered (the overlap grew after it judged). */
|
|
8099
|
+
unjudged(text) {
|
|
8100
|
+
const seen = new Set(this.words(this.overlapKept));
|
|
8101
|
+
return this.words(text).some((w) => !seen.has(w));
|
|
8102
|
+
}
|
|
8103
|
+
/** The host's verdict on the overlap the engine is holding (overlapDecideMs, or any live overlap pause):
|
|
8104
|
+
* 'yield' cedes the floor (a final that landed meanwhile is dispatched); 'keep_talking' resumes playback and
|
|
8105
|
+
* absorbs the rest of THAT reaction — `heard`, the text the verdict judged (default: the live partial); its final
|
|
8106
|
+
* is dropped. Words beyond `heard` are a new overlap, decided afresh: if the partial/final has already grown past
|
|
8107
|
+
* it, the hold stays and `overlap_pause` is re-emitted for a new verdict (bounded by overlapDecideMs, else it
|
|
8108
|
+
* 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). */
|
|
8109
|
+
decideOverlap(choice, heard) {
|
|
8110
|
+
if (!this.pausedAt || !this.speaking) return false;
|
|
8111
|
+
if (choice === "yield") {
|
|
8112
|
+
this.cedeOverlap("host_yield", this.lastOverlapPartial);
|
|
8113
|
+
return true;
|
|
8114
|
+
}
|
|
8115
|
+
this.overlapKept = heard?.trim() || this.lastOverlapPartial;
|
|
8116
|
+
const grown = this.decideHeldFinal || this.lastOverlapPartial;
|
|
8117
|
+
if (this.unjudged(grown)) {
|
|
8118
|
+
if (!(this.options.overlapDecideMs > 0 && this.shortOverlap(grown))) {
|
|
8119
|
+
this.cedeOverlap("overlap_cede", grown);
|
|
8120
|
+
return false;
|
|
8121
|
+
}
|
|
8122
|
+
this.diag("overlap_pause", { text: grown.slice(0, 60), redecide: true });
|
|
8123
|
+
this.awaitOverlapDecision(grown, true);
|
|
8124
|
+
return false;
|
|
8125
|
+
}
|
|
8126
|
+
const held2 = this.decideHeldFinal;
|
|
8127
|
+
this.decideHeldFinal = "";
|
|
8128
|
+
this.diag("overlap_resume", { heldMs: Math.round(this.clock.now() - this.pausedAt), reason: "host_keep" });
|
|
8129
|
+
if (held2) this.diag("utterance_drop", { reason: "overlap_kept", text: held2.slice(0, 60) });
|
|
8130
|
+
this.overlapKeptUntil = held2 ? 0 : this.clock.now() + 4e3;
|
|
8131
|
+
this.resetOverlap(true);
|
|
8132
|
+
this.lastResumeAt = 0;
|
|
8133
|
+
return true;
|
|
8134
|
+
}
|
|
7464
8135
|
armResume() {
|
|
7465
8136
|
if (this.resumeTimer) this.clock.clearTimeout(this.resumeTimer);
|
|
7466
8137
|
this.resumeTimer = this.clock.setTimeout(() => {
|
|
@@ -7476,6 +8147,20 @@ var VoiceEngine = class _VoiceEngine {
|
|
|
7476
8147
|
this.clock.clearTimeout(this.resumeTimer);
|
|
7477
8148
|
this.resumeTimer = null;
|
|
7478
8149
|
}
|
|
8150
|
+
if (this.decideTimer) {
|
|
8151
|
+
this.clock.clearTimeout(this.decideTimer);
|
|
8152
|
+
this.decideTimer = null;
|
|
8153
|
+
}
|
|
8154
|
+
this.decideSince = 0;
|
|
8155
|
+
const held2 = this.decideHeldFinal;
|
|
8156
|
+
this.decideHeldFinal = "";
|
|
8157
|
+
if (held2) {
|
|
8158
|
+
this.diag("utterance_release", { reason: "overlap_hold_exit", text: held2.slice(0, 60) });
|
|
8159
|
+
this.pendingUtt = this.mergeUtterance(this.pendingUtt, held2);
|
|
8160
|
+
if (this.pendingTimer) this.clock.clearTimeout(this.pendingTimer);
|
|
8161
|
+
this.pendingTimer = this.clock.setTimeout(() => this.flushUtterance(), 0);
|
|
8162
|
+
}
|
|
8163
|
+
if (this.pausedAt) this.diag("overlap_end", { resumed: resume, heldMs: Math.round(this.clock.now() - this.pausedAt) });
|
|
7479
8164
|
if (this.pausedAt && resume) {
|
|
7480
8165
|
this.player.resume?.();
|
|
7481
8166
|
this.lastResumeAt = this.clock.now();
|
|
@@ -7541,7 +8226,8 @@ var SentenceCoalescer = class _SentenceCoalescer {
|
|
|
7541
8226
|
feed(delta) {
|
|
7542
8227
|
if (delta) this.buf += delta;
|
|
7543
8228
|
let cut = -1;
|
|
7544
|
-
|
|
8229
|
+
const decimal = (i) => this.buf[i] === "." && /\d/.test(this.buf[i - 1] ?? "") && (i + 1 === this.buf.length || /\d/.test(this.buf[i + 1]));
|
|
8230
|
+
for (let i = 0; i < this.buf.length; i++) if (_SentenceCoalescer.isEnd(this.buf[i]) && !decimal(i)) cut = i;
|
|
7545
8231
|
if (cut < 0) return "";
|
|
7546
8232
|
if (this.buf[cut] !== "\n") while (cut + 1 < this.buf.length && CLOSERS.includes(this.buf[cut + 1])) cut++;
|
|
7547
8233
|
const ready = this.buf.slice(0, cut + 1).trim();
|
|
@@ -7556,9 +8242,14 @@ var SentenceCoalescer = class _SentenceCoalescer {
|
|
|
7556
8242
|
};
|
|
7557
8243
|
var SpokenSplitter = class {
|
|
7558
8244
|
buf = "";
|
|
7559
|
-
|
|
8245
|
+
open = false;
|
|
7560
8246
|
/** True once any spoken char has ever been emitted (drives the no-spoken fallback). */
|
|
7561
8247
|
spokeAny = false;
|
|
8248
|
+
/** Inside a `<spoken>` block after the last feed. Once it is false, a caller coalescing sentences can flush: nothing
|
|
8249
|
+
* more of that block is coming (a held "…in 2026." would otherwise wait for the next spoken text or the stream end). */
|
|
8250
|
+
get inSpoken() {
|
|
8251
|
+
return this.open;
|
|
8252
|
+
}
|
|
7562
8253
|
/** Feed a delta; returns the spoken/detail spans completed by this chunk (either may be ''). */
|
|
7563
8254
|
feed(delta) {
|
|
7564
8255
|
this.buf += delta;
|
|
@@ -7572,26 +8263,26 @@ var SpokenSplitter = class {
|
|
|
7572
8263
|
let spoken = "";
|
|
7573
8264
|
let detail = "";
|
|
7574
8265
|
while (this.buf.length) {
|
|
7575
|
-
const tag = this.
|
|
8266
|
+
const tag = this.open ? CLOSE : OPEN;
|
|
7576
8267
|
const idx = this.buf.indexOf(tag);
|
|
7577
8268
|
if (idx >= 0) {
|
|
7578
8269
|
const text2 = this.buf.slice(0, idx);
|
|
7579
|
-
if (this.
|
|
8270
|
+
if (this.open) spoken += text2;
|
|
7580
8271
|
else detail += text2;
|
|
7581
8272
|
this.buf = this.buf.slice(idx + tag.length);
|
|
7582
|
-
this.
|
|
8273
|
+
this.open = !this.open;
|
|
7583
8274
|
continue;
|
|
7584
8275
|
}
|
|
7585
8276
|
const lt = this.buf.lastIndexOf("<");
|
|
7586
8277
|
const holdStart = lt >= 0 && tag.startsWith(this.buf.slice(lt)) ? lt : this.buf.length;
|
|
7587
8278
|
const text = this.buf.slice(0, holdStart);
|
|
7588
|
-
if (this.
|
|
8279
|
+
if (this.open) spoken += text;
|
|
7589
8280
|
else detail += text;
|
|
7590
8281
|
this.buf = this.buf.slice(holdStart);
|
|
7591
8282
|
break;
|
|
7592
8283
|
}
|
|
7593
8284
|
if (final && this.buf) {
|
|
7594
|
-
if (this.
|
|
8285
|
+
if (this.open) spoken += this.buf;
|
|
7595
8286
|
else detail += this.buf;
|
|
7596
8287
|
this.buf = "";
|
|
7597
8288
|
}
|
|
@@ -7606,7 +8297,11 @@ var now = () => performance.now();
|
|
|
7606
8297
|
var SonioxSTTOptions = class {
|
|
7607
8298
|
auth = "";
|
|
7608
8299
|
source;
|
|
7609
|
-
|
|
8300
|
+
/** `stt-rt-preview` was RETIRED (aliased to the two-generations-old v3 on 2025-10-31), so every
|
|
8301
|
+
* caller that never set this was silently running an old model while the alias kept it working —
|
|
8302
|
+
* the failure mode of a default that is a moving vendor pointer. Pin a real version instead;
|
|
8303
|
+
* `stt-rt-v5` is live today (verified against GET https://api.soniox.com/v1/models). */
|
|
8304
|
+
model = "stt-rt-v5";
|
|
7610
8305
|
languageHints = ["en"];
|
|
7611
8306
|
/** Client-side endpoint: finalized text + no new tokens for this long = utterance (don't wait for
|
|
7612
8307
|
* Soniox's semantic <end>, which adds 0.5-1.5s — the difference between ping-pong and lag). */
|
|
@@ -7979,6 +8674,15 @@ function base64ToBytes(b64) {
|
|
|
7979
8674
|
return out;
|
|
7980
8675
|
}
|
|
7981
8676
|
|
|
8677
|
+
// node_modules/@bod.ee/voice/src/adapters/fish.ts
|
|
8678
|
+
var log15 = forComponent2("FishTTS");
|
|
8679
|
+
|
|
8680
|
+
// node_modules/@bod.ee/voice/src/adapters/grok.ts
|
|
8681
|
+
var log16 = forComponent2("GrokTTS");
|
|
8682
|
+
|
|
8683
|
+
// node_modules/@bod.ee/voice/src/adapters/grok-stt.ts
|
|
8684
|
+
var log17 = forComponent2("GrokSTT");
|
|
8685
|
+
|
|
7982
8686
|
// src/voice/engine.ts
|
|
7983
8687
|
init_logging();
|
|
7984
8688
|
configureLogging((name) => {
|
|
@@ -7987,8 +8691,431 @@ configureLogging((name) => {
|
|
|
7987
8691
|
return { debug: at("debug"), verbose: at("verbose"), info: at("info"), warn: at("warn"), error: at("error") };
|
|
7988
8692
|
});
|
|
7989
8693
|
|
|
8694
|
+
// src/voice/instinct.ts
|
|
8695
|
+
init_logging();
|
|
8696
|
+
var log18 = forComponent("Instinct");
|
|
8697
|
+
var InstinctOptions = class {
|
|
8698
|
+
decider;
|
|
8699
|
+
/** Partial unchanged this long (a micro-pause) → consult. */
|
|
8700
|
+
stableMs = 250;
|
|
8701
|
+
/** Don't consult on fragments shorter than this. */
|
|
8702
|
+
minWords = 4;
|
|
8703
|
+
/** Act only on the TOP choice, and only above its floor (live eval 2026-09-23: a correct cut-in often
|
|
8704
|
+
* tops at 0.6-0.8; a mid-thought "we could maybe" drew a 0.58 backchannel that must not fire). */
|
|
8705
|
+
backchannelMin = 0.65;
|
|
8706
|
+
interjectMin = 0.6;
|
|
8707
|
+
yieldMin = 0.7;
|
|
8708
|
+
backchannelGapMs = 5e3;
|
|
8709
|
+
interjectGapMs = 2e4;
|
|
8710
|
+
/** A decision whose partial grew by this many words while in flight is stale → dropped. */
|
|
8711
|
+
staleWords = 3;
|
|
8712
|
+
/** Overlap: consult only once the engine has HELD the pause this long without ceding. The engine cedes on
|
|
8713
|
+
* the 2nd novel word (~200ms after the pause, live 2026-09-23) — a call fired at the pause lands after that
|
|
8714
|
+
* (~300-400ms) and can never act. A hold that survives this window is the undecided case (a 1-word overlap
|
|
8715
|
+
* the engine would RESUME over at overlapResumeMs=700) — the one where a yield actually changes the outcome. */
|
|
8716
|
+
overlapSettleMs = 250;
|
|
8717
|
+
/** ackFor waits this long for the host's ackOutcome before treating the ack as unspoken. Hosts set it to the engine's
|
|
8718
|
+
* last chance to fire (bod-voice: 2×adaptiveAckMs — past that a pending pick is dropped as pick_late). */
|
|
8719
|
+
ackSettleMs = 1500;
|
|
8720
|
+
context = () => ({});
|
|
8721
|
+
/** Actions return `false` when the host could not act (engine state moved on) → logged as `<act>_skipped`. */
|
|
8722
|
+
onBackchannel = () => {
|
|
8723
|
+
};
|
|
8724
|
+
onInterject = () => {
|
|
8725
|
+
};
|
|
8726
|
+
onYield = () => {
|
|
8727
|
+
};
|
|
8728
|
+
/** The overlap was judged a listener cue — carry on. Hosts whose engine HOLDS short overlaps for a verdict
|
|
8729
|
+
* (bod-voice overlapDecideMs) resume it here; otherwise leave the default no-op (the engine decides). */
|
|
8730
|
+
onKeepTalking = () => {
|
|
8731
|
+
};
|
|
8732
|
+
onDecision = () => {
|
|
8733
|
+
};
|
|
8734
|
+
now = () => performance.now();
|
|
8735
|
+
};
|
|
8736
|
+
var ACK_SITUATIONS = {
|
|
8737
|
+
hard_question: `a question that takes real thought, judgment or looking something up ("what's the biggest takeaway?", "how should I structure this?")`,
|
|
8738
|
+
simple_question: 'a quick question with an instant, obvious answer ("how are you?", "what time is it?", "can you hear me?")',
|
|
8739
|
+
emotional_bad: "they share something bad, sad, painful, worrying or frustrating",
|
|
8740
|
+
emotional_good: "they share good news, excitement, relief or pride",
|
|
8741
|
+
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")`,
|
|
8742
|
+
surprise: `they react with surprise or disbelief to what you said ("oh, that's new", "really?", "no way", "oh.")`,
|
|
8743
|
+
agreement: `they agree with you, confirm, or tell you to go on ("exactly", "yes, that's it", "okay, so continue")`,
|
|
8744
|
+
goodbye: "they are wrapping up the conversation or saying goodbye",
|
|
8745
|
+
command: 'a request or instruction for you to do, find, check or tell them something ("tell me the news", "check my calendar")',
|
|
8746
|
+
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")'
|
|
8747
|
+
};
|
|
8748
|
+
var ACK_BANK = {
|
|
8749
|
+
hard_question: ["Hmm, let me think on that.", "Ooh, interesting one.", "Hm, that's a big one.", "Let's see here."],
|
|
8750
|
+
simple_question: ["Oh, well, so\u2014", "Well, let's see\u2014", "Oh, easy one\u2014"],
|
|
8751
|
+
challenge: ["Fair question.", "Mm, fair to ask.", "Hm, okay, so\u2014", "Ah, I get why\u2014"],
|
|
8752
|
+
emotional_bad: ["Oh, I'm sorry.", "Ugh, that's rough.", "Aw, that's hard.", "Oh man, I'm sorry."],
|
|
8753
|
+
emotional_good: ["Oh, that's great!", "Oh, that's awesome!", "Oh, nice one!", "Love to hear it!"],
|
|
8754
|
+
surprise: ["Right? Pretty wild.", "Yeah, wild, huh?", "Huh, I know.", "Wild, isn't it?"],
|
|
8755
|
+
agreement: ["Mm, good, so\u2014", "Yeah, so\u2014", "Alright, so\u2014", "Yep, with you."],
|
|
8756
|
+
goodbye: ["Alright, catch you later.", "Great chatting.", "Okay, all set then."],
|
|
8757
|
+
command: ["On it.", "Sure, one sec.", "Got it, one moment.", "Sure thing, one sec."],
|
|
8758
|
+
silent: [],
|
|
8759
|
+
// interruption acks, keyed on the barge intent
|
|
8760
|
+
stop: ["Sure, stopping.", "Okay, stopping here.", "Got it, I'll stop."],
|
|
8761
|
+
aside: ["Oh, quick one\u2014", "Ah, sure, so\u2014", "Oh, right, so\u2014"],
|
|
8762
|
+
steer: ["Ah, okay, switching\u2014", "Ah, got it, so\u2014", "Oh, gotcha, sure\u2014"],
|
|
8763
|
+
resume: ["Right, so\u2014", "Anyway, so\u2014", "So, as I was saying\u2014"]
|
|
8764
|
+
};
|
|
8765
|
+
var INTENT_ACK_OVER = {
|
|
8766
|
+
stop: Object.keys(ACK_SITUATIONS).filter((s) => s !== "silent"),
|
|
8767
|
+
steer: ["command"],
|
|
8768
|
+
resume: ["agreement", "silent"],
|
|
8769
|
+
aside: []
|
|
8770
|
+
};
|
|
8771
|
+
function interruptionNote(cut, intent) {
|
|
8772
|
+
const full = cut.full.trim();
|
|
8773
|
+
let at = Math.min(cut.heard.length, cut.full.length);
|
|
8774
|
+
if (/\S/.test(cut.full[at] ?? "") && /\S/.test(cut.full[at - 1] ?? "")) at = cut.full.lastIndexOf(" ", at) + 1;
|
|
8775
|
+
const heardWords = cut.full.slice(0, at).trim().split(/\s+/).filter(Boolean).length;
|
|
8776
|
+
const unheard = cut.full.slice(at).trim();
|
|
8777
|
+
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}".`;
|
|
8778
|
+
const how = {
|
|
8779
|
+
resume: "They did not ask anything new. Carry on with the part they missed \u2014 no restart, no apology.",
|
|
8780
|
+
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.",
|
|
8781
|
+
steer: "They steered away. Follow their new direction; carry over only what still matters from your unfinished point, or drop it.",
|
|
8782
|
+
stop: "They want you to stop. Do not resume; at most a few words of acknowledgement.",
|
|
8783
|
+
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."
|
|
8784
|
+
};
|
|
8785
|
+
return `
|
|
8786
|
+
[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.]`;
|
|
8787
|
+
}
|
|
8788
|
+
var FRAME = "You are the instinct of a warm, attentive human in a live spoken conversation. Decide what a natural human would do THIS instant \u2014 the way people do without thinking. ";
|
|
8789
|
+
var acted = (what, r) => r === false ? `${what}_skipped` : what;
|
|
8790
|
+
var words = (s) => s.trim() ? s.trim().split(/\s+/).length : 0;
|
|
8791
|
+
var Instinct = class {
|
|
8792
|
+
constructor(options) {
|
|
8793
|
+
this.options = options;
|
|
8794
|
+
this.options = { ...new InstinctOptions(), ...options };
|
|
8795
|
+
}
|
|
8796
|
+
options;
|
|
8797
|
+
partial = "";
|
|
8798
|
+
turnStart = 0;
|
|
8799
|
+
timer = null;
|
|
8800
|
+
inflight = false;
|
|
8801
|
+
lastBcAt = -Infinity;
|
|
8802
|
+
lastInterjectAt = -Infinity;
|
|
8803
|
+
lastIntent;
|
|
8804
|
+
overlapGen = 0;
|
|
8805
|
+
// bumped by overlapEnded / a newer overlap → cancels a pending or in-flight overlap consult
|
|
8806
|
+
lastAckPick;
|
|
8807
|
+
interjecting = false;
|
|
8808
|
+
// inside onInterject — see pickAck
|
|
8809
|
+
lastAckSaid = "";
|
|
8810
|
+
get o() {
|
|
8811
|
+
return this.options;
|
|
8812
|
+
}
|
|
8813
|
+
/** A live user partial while the agent is LISTENING. Consults once the partial goes stable. */
|
|
8814
|
+
hear(text) {
|
|
8815
|
+
const t = text.trim();
|
|
8816
|
+
if (!t || t === this.partial) return;
|
|
8817
|
+
this.partial = t;
|
|
8818
|
+
if (!this.turnStart) this.turnStart = this.o.now();
|
|
8819
|
+
if (this.timer) clearTimeout(this.timer);
|
|
8820
|
+
this.timer = setTimeout(() => {
|
|
8821
|
+
this.timer = null;
|
|
8822
|
+
void this.consult(t);
|
|
8823
|
+
}, this.o.stableMs);
|
|
8824
|
+
}
|
|
8825
|
+
/** The user's turn ended (final dispatched, or the agent took the floor) — forget the partial, and drop any
|
|
8826
|
+
* pending overlap consult: whatever hold it was about is over. */
|
|
8827
|
+
reset() {
|
|
8828
|
+
this.overlapGen++;
|
|
8829
|
+
if (this.timer) clearTimeout(this.timer);
|
|
8830
|
+
this.timer = null;
|
|
8831
|
+
this.partial = "";
|
|
8832
|
+
this.turnStart = 0;
|
|
8833
|
+
}
|
|
8834
|
+
async consult(heard) {
|
|
8835
|
+
if (this.inflight || words(heard) < this.o.minWords) return;
|
|
8836
|
+
this.inflight = true;
|
|
8837
|
+
try {
|
|
8838
|
+
const now4 = this.o.now();
|
|
8839
|
+
const d = await this.ask("listen", heard, {
|
|
8840
|
+
heard_so_far: heard,
|
|
8841
|
+
user_speaking_for_ms: Math.round(now4 - this.turnStart),
|
|
8842
|
+
ms_since_our_last_backchannel: Number.isFinite(this.lastBcAt) ? Math.round(now4 - this.lastBcAt) : null
|
|
8843
|
+
}, FRAME + "The user is mid-turn and just paused for a split second. Do you keep quiet, give a tiny listener cue, or cut in? Cut in only when a human really would: they are clearly wrong about something that matters, they are struggling for a word you know, or they asked something and are trailing off.", {
|
|
8844
|
+
stay_silent: "keep listening \u2014 they are mid-thought, nothing to add",
|
|
8845
|
+
backchannel: 'a tiny "mm-hm"/"right" to show you are following, without taking the turn (fits after a clause in a longer story or explanation)',
|
|
8846
|
+
interject: "cut in now with a short correction, the word they are reaching for, or an answer"
|
|
8847
|
+
});
|
|
8848
|
+
if (!d) return;
|
|
8849
|
+
if (!this.partial || words(this.partial) - words(heard) >= this.o.staleWords) return this.o.onDecision({ ...d, acted: "stale" });
|
|
8850
|
+
const p = d.probabilities, t = this.o.now();
|
|
8851
|
+
if (d.choice === "interject" && p.interject >= this.o.interjectMin && t - this.lastInterjectAt > this.o.interjectGapMs) {
|
|
8852
|
+
this.lastInterjectAt = t;
|
|
8853
|
+
const at = this.partial;
|
|
8854
|
+
this.reset();
|
|
8855
|
+
this.interjecting = true;
|
|
8856
|
+
let r;
|
|
8857
|
+
try {
|
|
8858
|
+
r = this.o.onInterject(at);
|
|
8859
|
+
} finally {
|
|
8860
|
+
this.interjecting = false;
|
|
8861
|
+
}
|
|
8862
|
+
this.o.onDecision({ ...d, acted: acted("interject", r) });
|
|
8863
|
+
} else if (d.choice === "backchannel" && p.backchannel >= this.o.backchannelMin && t - this.lastBcAt > this.o.backchannelGapMs) {
|
|
8864
|
+
this.lastBcAt = t;
|
|
8865
|
+
this.o.onDecision({ ...d, acted: acted("backchannel", this.o.onBackchannel()) });
|
|
8866
|
+
} else this.o.onDecision(d);
|
|
8867
|
+
} finally {
|
|
8868
|
+
this.inflight = false;
|
|
8869
|
+
}
|
|
8870
|
+
}
|
|
8871
|
+
/** The user started talking OVER the agent (engine paused on a genuine overlap). Yield or keep going?
|
|
8872
|
+
* Consulted only if the engine is STILL holding after overlapSettleMs — see overlapEnded(). */
|
|
8873
|
+
async overlap(heard) {
|
|
8874
|
+
if (!heard.trim()) return;
|
|
8875
|
+
const gen = ++this.overlapGen;
|
|
8876
|
+
if (this.o.overlapSettleMs > 0) await new Promise((r) => setTimeout(r, this.o.overlapSettleMs));
|
|
8877
|
+
if (gen !== this.overlapGen) return;
|
|
8878
|
+
const d = await this.ask(
|
|
8879
|
+
"overlap",
|
|
8880
|
+
heard,
|
|
8881
|
+
{ user_said_over_us: heard },
|
|
8882
|
+
FRAME + "You are mid-sentence and the user just said something over you. Would a human stop and give them the floor, or treat it as a listener cue and carry on?",
|
|
8883
|
+
{
|
|
8884
|
+
yield: 'they want the floor \u2014 an objection, question, correction, "stop"/"wait", or new information',
|
|
8885
|
+
keep_talking: 'just a listener cue or agreement ("yeah", "mm-hm", "right", "okay") \u2014 carry on'
|
|
8886
|
+
}
|
|
8887
|
+
);
|
|
8888
|
+
if (d && gen !== this.overlapGen) this.o.onDecision({ ...d, acted: "stale" });
|
|
8889
|
+
else if (d && (d.probabilities.yield ?? 0) >= this.o.yieldMin) {
|
|
8890
|
+
this.o.onDecision({ ...d, acted: acted("yield", this.o.onYield(heard)) });
|
|
8891
|
+
} else if (d?.choice === "keep_talking") {
|
|
8892
|
+
this.o.onDecision({ ...d, acted: acted("keep_talking", this.o.onKeepTalking(heard)) });
|
|
8893
|
+
} else if (d) this.o.onDecision(d);
|
|
8894
|
+
}
|
|
8895
|
+
/** The engine's hold ended (it ceded the floor, resumed over a stalled overlap, began a new turn, or was
|
|
8896
|
+
* interrupted) — drop any pending/in-flight overlap consult. Hosts call this on every hold exit. */
|
|
8897
|
+
overlapEnded() {
|
|
8898
|
+
this.overlapGen++;
|
|
8899
|
+
}
|
|
8900
|
+
/** What the user meant by cutting us off mid-speech. Memoized per text (the duplex judge and the host's
|
|
8901
|
+
* note builder ask about the same line). Rejects on decider failure — callers pick their fallback. */
|
|
8902
|
+
bargeIntent(heard) {
|
|
8903
|
+
if (this.lastIntent?.heard === heard) return this.lastIntent.p;
|
|
8904
|
+
const p = this.ask(
|
|
8905
|
+
"barge_intent",
|
|
8906
|
+
heard,
|
|
8907
|
+
{ user_said_after_cutting_us_off: heard },
|
|
8908
|
+
FRAME + "You were in the middle of saying something (what_we_said_last) and the user cut you off with this. What would a human take from it?",
|
|
8909
|
+
{
|
|
8910
|
+
resume: 'nothing new \u2014 a filler, apology, or "go on": just pick up where you left off',
|
|
8911
|
+
aside: "a quick question, comment, or clarification about what you were saying \u2014 answer it, then carry on with your point",
|
|
8912
|
+
steer: "they are redirecting \u2014 a different topic, a correction of your plan, or a new request that replaces what you were saying",
|
|
8913
|
+
stop: "they want you to stop talking or drop it"
|
|
8914
|
+
},
|
|
8915
|
+
true
|
|
8916
|
+
).then((d) => {
|
|
8917
|
+
if (d) this.o.onDecision({ ...d, acted: d.choice });
|
|
8918
|
+
return d?.choice ?? "steer";
|
|
8919
|
+
});
|
|
8920
|
+
this.lastIntent = { heard, p };
|
|
8921
|
+
return p;
|
|
8922
|
+
}
|
|
8923
|
+
/** The ack for a finished user turn (engine `pickAck`, asked at dispatch): one Choice over ACK_SITUATIONS, run in
|
|
8924
|
+
* parallel with the reflex. After a barge the interruption's intent (bargeIntent, memoized — the same call the note
|
|
8925
|
+
* builder makes) replaces the situations in INTENT_ACK_OVER with its own ack; the rest keep the situational ack.
|
|
8926
|
+
* `null` = say nothing. Never rejects: a failed Choice → silence (logged in ask) — a wrong ack is worse than none,
|
|
8927
|
+
* and a slow reflex still gets the dead-air repair.
|
|
8928
|
+
* A cut-in (asked from inside onInterject) is always silent: it has no dispatch, so the engine's context carries the
|
|
8929
|
+
* user's PREVIOUS final (live: "My dog died yesterday." re-acked "Oh no." before a cut-in correction), and the
|
|
8930
|
+
* unfinished partial is no turn to react to — the interjection itself is the reply. */
|
|
8931
|
+
pickAck(ctx) {
|
|
8932
|
+
if (this.interjecting) {
|
|
8933
|
+
log18.debug(`ack: cut-in turn \u2192 silent (engine context "${ctx.text.slice(0, 40)}" is the previous final)`);
|
|
8934
|
+
return Promise.resolve(null);
|
|
8935
|
+
}
|
|
8936
|
+
const text = ctx.text.trim();
|
|
8937
|
+
const p = (async () => {
|
|
8938
|
+
const intentP = ctx.barged ? this.bargeIntent(text).catch((e) => {
|
|
8939
|
+
log18.warn(`barge intent for the ack failed \u2014 situational ack: ${e.message}`);
|
|
8940
|
+
return null;
|
|
8941
|
+
}) : null;
|
|
8942
|
+
const d = await this.ask(
|
|
8943
|
+
"ack",
|
|
8944
|
+
text,
|
|
8945
|
+
{ user_just_said: text },
|
|
8946
|
+
FRAME + "The user just finished speaking (user_just_said) and your real reply is a moment away. Classify what they said, so the fitting quick acknowledgement can be voiced first. Pick the category that matches their words; most requests and questions are NOT silent.",
|
|
8947
|
+
ACK_SITUATIONS
|
|
8948
|
+
);
|
|
8949
|
+
const sit = d?.choice ?? "silent";
|
|
8950
|
+
const intent = intentP ? await intentP : null;
|
|
8951
|
+
const key = intent && INTENT_ACK_OVER[intent].includes(sit) ? intent : sit;
|
|
8952
|
+
const phrase = this.rotate(ACK_BANK[key] ?? []);
|
|
8953
|
+
if (d) this.o.onDecision({ ...d, acted: phrase ? `${key}: ${phrase}` : `${key}: silent` });
|
|
8954
|
+
return phrase;
|
|
8955
|
+
})();
|
|
8956
|
+
let settle;
|
|
8957
|
+
const spoken = new Promise((r) => {
|
|
8958
|
+
settle = r;
|
|
8959
|
+
});
|
|
8960
|
+
const timer = setTimeout(() => {
|
|
8961
|
+
log18.debug(`ack: no engine outcome in ${this.o.ackSettleMs}ms \u2014 not claimed`);
|
|
8962
|
+
settle(null);
|
|
8963
|
+
}, this.o.ackSettleMs);
|
|
8964
|
+
void spoken.then(() => clearTimeout(timer));
|
|
8965
|
+
this.lastAckPick = { text, p, spoken, settle };
|
|
8966
|
+
return p;
|
|
8967
|
+
}
|
|
8968
|
+
/** The host reports what the engine did with the armed ack: the phrase it actually spoke, or null (silent, late,
|
|
8969
|
+
* cancelled). First report per pick wins — a later turn's cancel of an already-spoken ack changes nothing. */
|
|
8970
|
+
ackOutcome(said) {
|
|
8971
|
+
this.lastAckPick?.settle(said);
|
|
8972
|
+
}
|
|
8973
|
+
/** What was said out loud to open the reply to this exact text — lets a host tell the reply turn, without a second
|
|
8974
|
+
* call. Only an ack the engine actually SPOKE (ackOutcome): a pick that lands after the engine's deadline is never
|
|
8975
|
+
* claimed. null: nothing picked, not spoken, not asked, or no outcome within ackSettleMs of the pick's start.
|
|
8976
|
+
* Never waits past that cap (a hung pick included), and returns as soon as the outcome settles. */
|
|
8977
|
+
async ackFor(text) {
|
|
8978
|
+
const pick = this.lastAckPick;
|
|
8979
|
+
if (pick?.text !== text.trim()) return null;
|
|
8980
|
+
return Promise.race([pick.p.then((p) => p ? pick.spoken : null), pick.spoken]);
|
|
8981
|
+
}
|
|
8982
|
+
/** A random phrase from `pool`, never the one said last (no back-to-back repeat across keys). */
|
|
8983
|
+
rotate(pool) {
|
|
8984
|
+
const fresh = pool.filter((x) => x !== this.lastAckSaid);
|
|
8985
|
+
const from = fresh.length ? fresh : pool;
|
|
8986
|
+
if (!from.length) return null;
|
|
8987
|
+
return this.lastAckSaid = from[Math.floor(Math.random() * from.length)];
|
|
8988
|
+
}
|
|
8989
|
+
async ask(kind, heard, state, instructions, criteria, rethrow = false) {
|
|
8990
|
+
const t0 = this.o.now();
|
|
8991
|
+
try {
|
|
8992
|
+
const ctx = this.o.context();
|
|
8993
|
+
const { answers } = await this.o.decider.decide({
|
|
8994
|
+
state: { ...state, what_we_said_last: ctx.agentSaid || null, recent_conversation: ctx.recent || null },
|
|
8995
|
+
questions: { act: { type: "choice", instructions, criteria } }
|
|
8996
|
+
});
|
|
8997
|
+
return { kind, heard, choice: answers.act.choice, probabilities: answers.act.probabilities, ms: Math.round(this.o.now() - t0) };
|
|
8998
|
+
} catch (e) {
|
|
8999
|
+
log18.warn(`${kind} decision failed \u2014 falling back to engine defaults: ${e.message}`);
|
|
9000
|
+
if (rethrow) throw e;
|
|
9001
|
+
return null;
|
|
9002
|
+
}
|
|
9003
|
+
}
|
|
9004
|
+
};
|
|
9005
|
+
|
|
9006
|
+
// src/voice/unsaid.ts
|
|
9007
|
+
init_logging();
|
|
9008
|
+
var log19 = forComponent("Unsaid");
|
|
9009
|
+
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(" "));
|
|
9010
|
+
var contentStems = (s) => (s.toLowerCase().match(/[a-z0-9']+/g) ?? []).filter((w) => w.length >= 4 && !STOP2.has(w)).map((w) => w.slice(0, 4));
|
|
9011
|
+
var SAID_OVERLAP = 0.6;
|
|
9012
|
+
var UNSAID_OVERLAP = 0.25;
|
|
9013
|
+
var CONVEYED_MIN = 0.3;
|
|
9014
|
+
function splitClauses(text) {
|
|
9015
|
+
return text.split(/(?<=[.!?…])\s+|(?<=;)\s+|(?<=,)\s+(?=(?:and|but)\b)/i).map((s) => s.trim()).filter((s) => contentStems(s).length > 0);
|
|
9016
|
+
}
|
|
9017
|
+
var overlap = (clause, said) => {
|
|
9018
|
+
const w = contentStems(clause);
|
|
9019
|
+
return w.filter((x) => said.has(x)).length / w.length;
|
|
9020
|
+
};
|
|
9021
|
+
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.";
|
|
9022
|
+
async function unsaidClauses(pieces, spoken, decider, timeoutMs = 5e3) {
|
|
9023
|
+
const said = new Set(contentStems(spoken));
|
|
9024
|
+
const open = splitClauses(pieces.join(" ")).map((c) => ({ c, r: overlap(c, said) })).filter((x) => x.r < SAID_OVERLAP);
|
|
9025
|
+
if (!open.length) return [];
|
|
9026
|
+
const fallback = () => open.filter((x) => x.r < UNSAID_OVERLAP).map((x) => x.c);
|
|
9027
|
+
if (!decider) return fallback();
|
|
9028
|
+
const state = { assistant_said: spoken };
|
|
9029
|
+
const questions = {};
|
|
9030
|
+
open.forEach((x, i) => {
|
|
9031
|
+
state[`c${i}`] = x.c;
|
|
9032
|
+
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` } };
|
|
9033
|
+
});
|
|
9034
|
+
let timer;
|
|
9035
|
+
try {
|
|
9036
|
+
const { answers } = await Promise.race([
|
|
9037
|
+
decider.decide({ state, questions }),
|
|
9038
|
+
new Promise((_, rej) => {
|
|
9039
|
+
timer = setTimeout(() => rej(new Error(`no verdict in ${timeoutMs}ms`)), timeoutMs);
|
|
9040
|
+
})
|
|
9041
|
+
]);
|
|
9042
|
+
return open.filter((_, i) => {
|
|
9043
|
+
const p = Number(answers[`c${i}`]?.noul);
|
|
9044
|
+
if (!Number.isFinite(p)) throw new Error(`no noul for c${i}`);
|
|
9045
|
+
return p < CONVEYED_MIN;
|
|
9046
|
+
}).map((x) => x.c);
|
|
9047
|
+
} catch (e) {
|
|
9048
|
+
log19.warn(`unsaid judge failed \u2014 only near-zero-overlap clauses count as unsaid: ${e.message}`);
|
|
9049
|
+
return fallback();
|
|
9050
|
+
} finally {
|
|
9051
|
+
clearTimeout(timer);
|
|
9052
|
+
}
|
|
9053
|
+
}
|
|
9054
|
+
|
|
9055
|
+
// src/voice/leak.ts
|
|
9056
|
+
var TOOL = String.raw`(?:QuickLook|TaskStatus|CancelTask|AnswerTask|ExitSession|Act|Think|Hold(?! on\b))`;
|
|
9057
|
+
var SUSPECT = [
|
|
9058
|
+
/\b(?:user|assistant)\b/i,
|
|
9059
|
+
/\b(?:instructions?|guidelines?|polic(?:y|ies)|system prompt|developer (?:message|instructions?|prompt))\b/i,
|
|
9060
|
+
new RegExp(String.raw`\b${TOOL}\b`),
|
|
9061
|
+
/\bwe (?:need to|have to|should|must|ought to)\b|(?:^|[.!?]\s*)(?:need to|should|must|let's)\b/i,
|
|
9062
|
+
/\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,
|
|
9063
|
+
/\b(?:respond|reply|output|produce|comply|acknowledge?)\w*\b/i,
|
|
9064
|
+
/\bwe(?: have|'ve)\b|\bwe (?:already )?(?:responded|replied|dispatched|used|called|did|gave|said|asked)\b/i,
|
|
9065
|
+
/\bno (?:further|more|other)\b/i,
|
|
9066
|
+
/\[\s*task\b|\bt\d+\b|\bwait(?:ing)? for (?:the )?(?:events?|results?|task)\b/i,
|
|
9067
|
+
/\w\s*\{\s*"|\bwhat:\s*"/,
|
|
9068
|
+
/\b(?:last|previous) (?:response|reply|message|turn)\b|\bthe (?:conversation|transcript|context)\b/i
|
|
9069
|
+
];
|
|
9070
|
+
var STRICT = [
|
|
9071
|
+
// the listener as a third-person subject opening the sentence ("The user wants…", telegraphic "User wants…")
|
|
9072
|
+
/^(?: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,
|
|
9073
|
+
/\b(?:if|when|since|because) user (?:is|keeps|wants|says|said|asks|asked|seems)\b/i,
|
|
9074
|
+
/\bwe have (?:an? )?(?:user (?:who|request|message|asking|saying)|conversation (?:where|with)|new user message)\b|^we have task\b/i,
|
|
9075
|
+
/\bthe assistant(?:'s (?:context|last|previous) (?:response|reply|turn|message)| (?:responded|replied) with)\b/i,
|
|
9076
|
+
// planning its own reply
|
|
9077
|
+
/\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,
|
|
9078
|
+
/\b(?:we|i) (?:need to|should|must) output\b|^(?:we|i) must comply[.!]?$/i,
|
|
9079
|
+
/\bno (?:further|more|other) (?:response|reply|output)s? (?:is )?(?:needed|required|necessary)\b/i,
|
|
9080
|
+
/\bwe(?: have|'ve)? (?:already )?(?:responded|replied)[.!]?$/i,
|
|
9081
|
+
// its own protocol: using its tools, tool-call syntax, task events
|
|
9082
|
+
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`),
|
|
9083
|
+
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`),
|
|
9084
|
+
/\bwait(?:ing)? for (?:the )?(?:events?|t\d+)\b|\bt\d+ (?:completed|failed)\b|\[\s*task\b/i
|
|
9085
|
+
];
|
|
9086
|
+
var SENTENCE_END = new RegExp(`[.!?\u2026]+["')\\]]*(?:\\s|(?=\\p{Lu}|\\[))`, "gu");
|
|
9087
|
+
var SENTENCE_SPLIT = new RegExp(`(?<=[.!?\u2026]+["')\\]]*)(?:\\s+|(?=\\p{Lu}|\\[))`, "u");
|
|
9088
|
+
var clean = (s) => s.replace(/\[[a-z_ ]+\]\s*/gi, "").trim();
|
|
9089
|
+
function isLeakSuspect(sentence) {
|
|
9090
|
+
const s = clean(sentence);
|
|
9091
|
+
return SUSPECT.some((re) => re.test(s));
|
|
9092
|
+
}
|
|
9093
|
+
function isStrictLeak(sentence) {
|
|
9094
|
+
return sentence.split(SENTENCE_SPLIT).some((x) => {
|
|
9095
|
+
const s = clean(x);
|
|
9096
|
+
return STRICT.some((re) => re.test(s));
|
|
9097
|
+
});
|
|
9098
|
+
}
|
|
9099
|
+
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.`;
|
|
9100
|
+
var LEAK_JUDGE_THRESHOLD = 0.6;
|
|
9101
|
+
async function judgeLeak(decider, sentence, userSaid) {
|
|
9102
|
+
const { answers } = await decider.decide({
|
|
9103
|
+
state: { what_the_user_just_said: userSaid.slice(-600) || null, sentence_about_to_be_spoken: clean(sentence) },
|
|
9104
|
+
questions: {
|
|
9105
|
+
q: {
|
|
9106
|
+
type: "noul",
|
|
9107
|
+
instructions: JUDGE_INSTRUCTIONS,
|
|
9108
|
+
criteria: { true: "leaked private reasoning \u2014 must not be spoken", false: "said to the listener \u2014 fine to speak" }
|
|
9109
|
+
}
|
|
9110
|
+
}
|
|
9111
|
+
});
|
|
9112
|
+
const p = Number(answers.q.noul);
|
|
9113
|
+
if (!Number.isFinite(p)) throw new Error("leak judge: no noul in the answer");
|
|
9114
|
+
return p;
|
|
9115
|
+
}
|
|
9116
|
+
|
|
7990
9117
|
// src/duplex.ts
|
|
7991
|
-
var
|
|
9118
|
+
var log20 = forComponent("DuplexAgent");
|
|
7992
9119
|
function describeCall(call) {
|
|
7993
9120
|
const v = call.args && Object.values(call.args).find((x) => typeof x === "string" && x.trim());
|
|
7994
9121
|
const hint = v ? ` (${String(v).replace(/\s+/g, " ").trim().slice(0, 48)})` : "";
|
|
@@ -8030,6 +9157,13 @@ var DuplexAgentOptions = class {
|
|
|
8030
9157
|
/** Teach the model to emit inline `[emotion]` tags for Cartesia emotion control. Only set when the
|
|
8031
9158
|
* TTS actually speaks them — text-duplex (no TTS) would otherwise print literal tags. */
|
|
8032
9159
|
emotionTags = false;
|
|
9160
|
+
/** Judge a post-barge line while a delivery is parked: resume it now, answer an aside and carry it on (the reflex,
|
|
9161
|
+
* told what was heard — see asideNote), or drop it (steer/stop). Unset → the conservative `isTrivialBarge` allowlist (resume | steer); a throwing judge
|
|
9162
|
+
* falls back to it too. */
|
|
9163
|
+
bargeIntent;
|
|
9164
|
+
/** Typed-decision model (e.g. ai.libx.js DecideClient → Jev). Judges reflex sentences the leak screen flags as
|
|
9165
|
+
* SUSPECT (src/voice/leak.ts) — only suspects pay the ~300ms call. Unset → only the strict leak shapes are cut. */
|
|
9166
|
+
decider;
|
|
8033
9167
|
/** Awaited BEFORE a worker spawns — open a per-task checkpoint frame, audit, etc.
|
|
8034
9168
|
* (post-spawn would race the worker's first edits). */
|
|
8035
9169
|
onTaskStart;
|
|
@@ -8038,6 +9172,13 @@ var DuplexAgentOptions = class {
|
|
|
8038
9172
|
progressUpdates = false;
|
|
8039
9173
|
/** Min ms between progress re-voices per task. */
|
|
8040
9174
|
progressIntervalMs = 25e3;
|
|
9175
|
+
/** Spoken budget of ONE task delivery (chars, ~3 short sentences). Past it the rest is parked — cut at a sentence
|
|
9176
|
+
* boundary, `spokenOffer` spoken instead — so "go on" continues it once (the parked/resume path) and an aside or
|
|
9177
|
+
* steer handles it like any parked delivery. The worker is also asked for a short spoken summary + one offer (the
|
|
9178
|
+
* source); this is the backstop. Live 20260924-093945: ~20s and ~70s monologues whose closing choice went unspoken.
|
|
9179
|
+
* The full result always folds into the transcript. 0 = unbudgeted. */
|
|
9180
|
+
spokenBudgetChars = 0;
|
|
9181
|
+
spokenOffer = "There's more. Want me to go on?";
|
|
8041
9182
|
/** Relay worker questions (AskUserQuestion + permission asks via parkQuestion) through the VOICE:
|
|
8042
9183
|
* the question re-voices as '[task <id> asks] …', the user answers conversationally, and the
|
|
8043
9184
|
* voice model resolves it with the AnswerTask tool. Off → host.ask passthrough (text menus). */
|
|
@@ -8056,20 +9197,30 @@ var DuplexAgentOptions = class {
|
|
|
8056
9197
|
/** User-scope memory dir for global facts (type=user/feedback). Forwarded to Remember's routing. */
|
|
8057
9198
|
memoryUserDir;
|
|
8058
9199
|
};
|
|
9200
|
+
var SESSION_ID_RE = /^\d{8}-\d{6}(?:-[\w-]+)?$/;
|
|
8059
9201
|
var RESERVED_EVENT_MARKER = /\[task\b[^\]\n]*\b(?:completed|failed|progress|asks)\b/i;
|
|
8060
9202
|
var RESERVED_EVENT_OPENER = /\[\s*task\b/i;
|
|
8061
9203
|
var STAGE_DIRECTION_RE = /^\(\s*(?:(?:waiting|checking|searching|thinking|processing|loading|working|fetching|looking)\b[^)]*|[^)]*(?:\.\.\.|…)\s*)\)$/i;
|
|
8062
9204
|
function nowLine() {
|
|
8063
9205
|
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" })}`;
|
|
8064
9206
|
}
|
|
9207
|
+
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/;
|
|
8065
9208
|
function isTrivialBarge(text) {
|
|
8066
9209
|
const t = text.trim().toLowerCase().replace(/[.,!?…\s]+$/g, "").replace(/^[.,!?…\s]+/, "");
|
|
8067
9210
|
if (!t) return false;
|
|
8068
|
-
if (
|
|
9211
|
+
if (STOP_INTENT.test(t)) return false;
|
|
8069
9212
|
const core = t.replace(/^(?:oh|um+|uh+|er+|hmm+|ah+|well|so|okay|ok|yeah|yep)[\s,]+/, "").trim();
|
|
8070
9213
|
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);
|
|
8071
9214
|
}
|
|
8072
|
-
|
|
9215
|
+
function isOfferAcceptance(text) {
|
|
9216
|
+
const t = text.trim().toLowerCase().replace(/[.,!?…]+/g, " ").replace(/\s+/g, " ").trim();
|
|
9217
|
+
if (!t || STOP_INTENT.test(t)) return false;
|
|
9218
|
+
const core = t.replace(/^(?:oh|um+|uh+|well|so|okay|ok|alright)\s+/, "");
|
|
9219
|
+
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);
|
|
9220
|
+
}
|
|
9221
|
+
var isQuestion = (s) => /\?["'”’)\]]*$/.test(s.trim());
|
|
9222
|
+
var OFFER_OVERRUN = 150;
|
|
9223
|
+
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.';
|
|
8073
9224
|
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.";
|
|
8074
9225
|
var THINK_DISABLED_GUIDANCE = "(Think tier is not available \u2014 use Act for all escalations.)";
|
|
8075
9226
|
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).`;
|
|
@@ -8084,14 +9235,22 @@ var DuplexAgent = class _DuplexAgent {
|
|
|
8084
9235
|
* (TaskRecord.createdTurn) so parking can tell a prior-topic task from the current turn's fresh one. */
|
|
8085
9236
|
turn = 0;
|
|
8086
9237
|
pendingEvents = [];
|
|
8087
|
-
/**
|
|
8088
|
-
*
|
|
9238
|
+
/** THE parked delivery: every piece the user has NOT heard, in speaking order — the unheard rest a barge cut out of
|
|
9239
|
+
* the voice queue (parkCutDelivery) followed by each segment a barge-parked task produced since (deliverSegment).
|
|
9240
|
+
* Nothing already voiced is ever added, so a resume never repeats what was heard. The next line's barge intent
|
|
9241
|
+
* decides: resume → spoken verbatim, aside → handed to the reflex to carry on after its answer (asideNote) — both
|
|
9242
|
+
* send the barge-parked tasks live again; steer/stop → cleared, and those tasks
|
|
9243
|
+
* are moved on from (the full result stays in the transcript, recoverable by asking). `task` = the owner, so a
|
|
9244
|
+
* task whose outcome goes to an integration turn takes its pieces back (dropParked). */
|
|
8089
9245
|
parkedRedeliver = [];
|
|
8090
|
-
/**
|
|
8091
|
-
|
|
8092
|
-
|
|
8093
|
-
|
|
8094
|
-
|
|
9246
|
+
/** parkedRedeliver length when the last barge parked (parkInFlightDeliveries): the cut it hands over later
|
|
9247
|
+
* (parkCutDelivery) was voiced BEFORE anything parked since, so it is inserted here. */
|
|
9248
|
+
bargeMark = 0;
|
|
9249
|
+
/** The last barge cut a REFLEX reply (parkCutDelivery): a "go on" is then the reflex's to answer — it runs a normal
|
|
9250
|
+
* turn (the host's interruption note continues the reply), and only then does the parked delivery resume. */
|
|
9251
|
+
reflexCut = false;
|
|
9252
|
+
/** Recent live task segments → their task id: attributes a cut delivery (parkCutDelivery) to the task that spoke it. */
|
|
9253
|
+
voicedBy = /* @__PURE__ */ new Map();
|
|
8095
9254
|
/** Out-of-band follow-up attribution for the events coalescing into the next flush turn: TRUE iff ≥1 of
|
|
8096
9255
|
* the tasks being integrated was NON-CLEAN (early-stop/failure). Carried out-of-band on the enqueue call
|
|
8097
9256
|
* by the caller that KNOWS the outcome — a plain boolean the MODEL CANNOT PERTURB. It is NOT scanned from
|
|
@@ -8124,6 +9283,24 @@ var DuplexAgent = class _DuplexAgent {
|
|
|
8124
9283
|
// chars of reflexBuf already forwarded to the host/TTS
|
|
8125
9284
|
fabricationCut = false;
|
|
8126
9285
|
// reflex emitted a reserved [task …] marker → suppress its tail
|
|
9286
|
+
leakCuts = [];
|
|
9287
|
+
// heads of analysis-register leaks cut this turn — scrubbed from the transcript at turn end
|
|
9288
|
+
leakStep = false;
|
|
9289
|
+
// the CURRENT step's tail is muted by a leak cut (the next step speaks again, unlike a fabricated marker)
|
|
9290
|
+
leakHit = false;
|
|
9291
|
+
// this turn (or its repair) had a leak cut — the dead-air fallback must not blame the user
|
|
9292
|
+
stepMarks = [];
|
|
9293
|
+
// reflexBuf offsets where a step ended (its text is complete there)
|
|
9294
|
+
leakVerdicts = /* @__PURE__ */ new Map();
|
|
9295
|
+
// judged suspect sentences this turn (true = leak)
|
|
9296
|
+
judging;
|
|
9297
|
+
// the in-flight leak judge (one at a time — the gate is sequential)
|
|
9298
|
+
turnGen = 0;
|
|
9299
|
+
// bumped whenever the gated stream is replaced — a late verdict for an old one is dropped
|
|
9300
|
+
turnSpoken = "";
|
|
9301
|
+
// reflex text actually forwarded to the host this turn
|
|
9302
|
+
turnBase = 0;
|
|
9303
|
+
// transcript length at turn start (scope for the leak scrub)
|
|
8127
9304
|
/** TRUE for the duration of a re-voice turn that is integrating ≥1 NON-CLEAN task (turn-eligibility,
|
|
8128
9305
|
* carried out-of-band — NOT derived from any worker/brief string). ANY Act/Think dispatched in such a
|
|
8129
9306
|
* turn is stamped followUp:true. This GUARANTEES the dangerous direction is impossible: a genuine
|
|
@@ -8172,7 +9349,8 @@ var DuplexAgent = class _DuplexAgent {
|
|
|
8172
9349
|
...new Set(workerToolNames.filter((n) => n.startsWith("mcp__")).map((n) => n.slice(5).split("__")[0]))
|
|
8173
9350
|
];
|
|
8174
9351
|
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' : "") : "";
|
|
8175
|
-
const prompt = VOICE_SYSTEM_PROMPT.replace("{{MEMORY_SLOT}}", memSlot).replace("{{THINK_SLOT}}", thinkSlot).replace("{{WORKER_WEB}}", workerWeb + workerMcp) + (o.
|
|
9352
|
+
const prompt = VOICE_SYSTEM_PROMPT.replace("{{MEMORY_SLOT}}", memSlot).replace("{{THINK_SLOT}}", thinkSlot).replace("{{WORKER_WEB}}", workerWeb + workerMcp) + (o.quickLook?.sessions ? `
|
|
9353
|
+
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 : "") + `
|
|
8176
9354
|
${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.`;
|
|
8177
9355
|
const tools = [
|
|
8178
9356
|
...o.reflexOptions?.tools ?? [],
|
|
@@ -8193,32 +9371,26 @@ ${nowLine()}. Anchor every relative or time-sensitive reference \u2014 "today",
|
|
|
8193
9371
|
if (this.heldThisTurn) return;
|
|
8194
9372
|
if (this.fabricationCut) return;
|
|
8195
9373
|
if (this.turnDispatched && this.spokeBeforeDispatch) return;
|
|
8196
|
-
|
|
8197
|
-
this.reflexBuf += msg;
|
|
8198
|
-
this.scrubStageDirections();
|
|
9374
|
+
this.reflexBuf += ev.message;
|
|
8199
9375
|
const m = this.reflexBuf.match(RESERVED_EVENT_MARKER) ?? this.reflexBuf.match(RESERVED_EVENT_OPENER);
|
|
8200
9376
|
if (m) {
|
|
8201
9377
|
this.fabricationCut = true;
|
|
8202
|
-
|
|
8203
|
-
|
|
8204
|
-
|
|
8205
|
-
if (safe.trim()) this.spokeThisTurn = true;
|
|
8206
|
-
this.emitHost({ ...ev, message: safe });
|
|
9378
|
+
log20.warn(`reflex fabricated a [task \u2026] event in its spoken stream \u2014 cutting it (kept ${m.index} chars)`);
|
|
9379
|
+
this.reflexBuf = this.reflexBuf.slice(0, Math.max(m.index, this.reflexForwarded));
|
|
9380
|
+
this.pumpReflex(true);
|
|
8207
9381
|
return;
|
|
8208
9382
|
}
|
|
8209
|
-
|
|
8210
|
-
const partial = held2 > 0 && /\[\s*t?a?s?k?$/i.test(this.reflexBuf.slice(-Math.min(held2, 6)));
|
|
8211
|
-
let upto = partial ? this.reflexBuf.length - this.reflexBuf.slice(-6).match(/\[\s*t?a?s?k?$/i)[0].length : this.reflexBuf.length;
|
|
8212
|
-
const paren = this.reflexBuf.lastIndexOf("(");
|
|
8213
|
-
if (paren >= this.reflexForwarded && !this.reflexBuf.includes(")", paren) && this.reflexBuf.length - paren <= 80)
|
|
8214
|
-
upto = Math.min(upto, paren);
|
|
8215
|
-
const out = this.reflexBuf.slice(this.reflexForwarded, upto);
|
|
8216
|
-
this.reflexForwarded = upto;
|
|
8217
|
-
if (!out) return;
|
|
8218
|
-
if (out.trim()) this.spokeThisTurn = true;
|
|
8219
|
-
this.emitHost({ ...ev, message: out });
|
|
9383
|
+
this.pumpReflex(false);
|
|
8220
9384
|
return;
|
|
8221
9385
|
}
|
|
9386
|
+
if (ev?.kind === "turn_start" || ev?.kind === "tool_use") {
|
|
9387
|
+
this.stepMarks.push(this.reflexBuf.length);
|
|
9388
|
+
if (this.leakStep) {
|
|
9389
|
+
this.leakStep = false;
|
|
9390
|
+
this.reflexForwarded = this.reflexBuf.length;
|
|
9391
|
+
}
|
|
9392
|
+
this.pumpReflex(false);
|
|
9393
|
+
}
|
|
8222
9394
|
host.notify?.(ev);
|
|
8223
9395
|
}
|
|
8224
9396
|
};
|
|
@@ -8252,16 +9424,163 @@ ${nowLine()}. Anchor every relative or time-sensitive reference \u2014 "today",
|
|
|
8252
9424
|
this.voice.options.tools.push(...mem.tools);
|
|
8253
9425
|
if (mem.index) this.voice.options.systemPrompt += "\n\n" + mem.index;
|
|
8254
9426
|
}
|
|
8255
|
-
/**
|
|
8256
|
-
*
|
|
8257
|
-
|
|
8258
|
-
|
|
8259
|
-
|
|
8260
|
-
|
|
8261
|
-
|
|
8262
|
-
|
|
8263
|
-
|
|
8264
|
-
|
|
9427
|
+
/** Forward what the reflex's spoken-stream gates allow (text_delta → host/TTS; forwarded audio can't be unsent):
|
|
9428
|
+
* - hold a trailing fragment that could be a `[task` opener split across deltas ("[ta" + "sk"), and an UNCLOSED
|
|
9429
|
+
* "(" (a stage direction is judged — and dropped — only once its ")" arrives; bounded at 80 chars);
|
|
9430
|
+
* - the leak gate: whole sentences only, each screened by isLeakSuspect the moment it completes — a strict match
|
|
9431
|
+
* is cut, any other suspect waits for the decider's verdict (judgeLeak) while the rest queues behind it.
|
|
9432
|
+
* `final` = the stream is done: release the holds and judge whatever is left as a sentence. Text before a step
|
|
9433
|
+
* boundary (stepMarks) is complete — no holds, its tail is a sentence. Idempotent; re-run as verdicts land. */
|
|
9434
|
+
pumpReflex(final) {
|
|
9435
|
+
for (; ; ) {
|
|
9436
|
+
if (this.leakStep) return;
|
|
9437
|
+
this.scrubStageDirections();
|
|
9438
|
+
const from = this.reflexForwarded;
|
|
9439
|
+
const buf = this.reflexBuf;
|
|
9440
|
+
const lastMark = Math.max(from, ...this.stepMarks);
|
|
9441
|
+
let upto = buf.length;
|
|
9442
|
+
if (!final && upto > lastMark) {
|
|
9443
|
+
const tail = buf.slice(Math.max(lastMark, upto - 6)).match(/\[\s*t?a?s?k?$/i);
|
|
9444
|
+
if (tail) upto -= tail[0].length;
|
|
9445
|
+
const paren = buf.lastIndexOf("(");
|
|
9446
|
+
if (paren >= lastMark && !buf.includes(")", paren) && buf.length - paren <= 80) upto = Math.min(upto, paren);
|
|
9447
|
+
}
|
|
9448
|
+
if (upto <= from) return;
|
|
9449
|
+
const region = buf.slice(from, upto);
|
|
9450
|
+
const ends = [...region.matchAll(SENTENCE_END)].map((m) => m.index + m[0].length);
|
|
9451
|
+
for (const k of this.stepMarks) if (k > from && k <= upto) ends.push(k - from);
|
|
9452
|
+
if (final || !ends.length && region.length >= 160) ends.push(region.length);
|
|
9453
|
+
ends.sort((a, b) => a - b);
|
|
9454
|
+
let ok = 0, leak;
|
|
9455
|
+
for (const e of ends) {
|
|
9456
|
+
if (e <= ok) continue;
|
|
9457
|
+
const v = this.leakVerdict(region.slice(ok, e));
|
|
9458
|
+
if (v === void 0) break;
|
|
9459
|
+
if (v) {
|
|
9460
|
+
leak = region.slice(ok, e);
|
|
9461
|
+
break;
|
|
9462
|
+
}
|
|
9463
|
+
ok = e;
|
|
9464
|
+
}
|
|
9465
|
+
this.forwardReflex(from + ok);
|
|
9466
|
+
if (leak === void 0) return;
|
|
9467
|
+
const at = from + ok;
|
|
9468
|
+
this.leakHit = true;
|
|
9469
|
+
this.leakCuts.push(leak.trim().slice(0, 40));
|
|
9470
|
+
log20.warn(`reflex spoke its own analysis ("${leak.trim().slice(0, 40)}\u2026") \u2014 cut before TTS`);
|
|
9471
|
+
this.notify("diag", "analysis_leak", { text: leak.trim().slice(0, 80), at: this.turnSpoken.trim() ? "mid_reply" : "before_speech" });
|
|
9472
|
+
const next = this.stepMarks.find((k) => k > at);
|
|
9473
|
+
if (next === void 0) {
|
|
9474
|
+
this.reflexForwarded = buf.length;
|
|
9475
|
+
this.leakStep = true;
|
|
9476
|
+
return;
|
|
9477
|
+
}
|
|
9478
|
+
this.reflexForwarded = next;
|
|
9479
|
+
}
|
|
9480
|
+
}
|
|
9481
|
+
forwardReflex(to) {
|
|
9482
|
+
const out = this.reflexBuf.slice(this.reflexForwarded, to);
|
|
9483
|
+
this.reflexForwarded = Math.max(this.reflexForwarded, to);
|
|
9484
|
+
if (!out) return;
|
|
9485
|
+
if (out.trim()) this.spokeThisTurn = true;
|
|
9486
|
+
this.turnSpoken += out;
|
|
9487
|
+
this.emitHost({ kind: "text_delta", message: out });
|
|
9488
|
+
}
|
|
9489
|
+
/** Leak verdict for one complete sentence: false = speak, true = cut, undefined = the judge is still out.
|
|
9490
|
+
* Non-suspects pass free; strict shapes are cut without a call; other suspects go to the decider (if any) —
|
|
9491
|
+
* one call at a time, cached per turn. A failed/slow judge fails OPEN (speech is never muted on a guess). */
|
|
9492
|
+
leakVerdict(sentence) {
|
|
9493
|
+
const s = sentence.trim();
|
|
9494
|
+
if (!s || !isLeakSuspect(s)) return false;
|
|
9495
|
+
if (isStrictLeak(s)) return true;
|
|
9496
|
+
const decider = this.options.decider;
|
|
9497
|
+
if (!decider) return false;
|
|
9498
|
+
const known = this.leakVerdicts.get(s);
|
|
9499
|
+
if (known !== void 0 || this.judging) return known;
|
|
9500
|
+
const gen = this.turnGen, t0 = performance.now();
|
|
9501
|
+
const userSaid = [...this.voice.transcript].reverse().map((m) => m.role === "user" ? contentText(m.content) : "").find((t) => t && !t.startsWith("[reminder]")) ?? "";
|
|
9502
|
+
let timer;
|
|
9503
|
+
const timeout = new Promise((_, rej) => {
|
|
9504
|
+
timer = setTimeout(() => rej(new Error(`no verdict in ${_DuplexAgent.LEAK_JUDGE_TIMEOUT_MS}ms`)), _DuplexAgent.LEAK_JUDGE_TIMEOUT_MS);
|
|
9505
|
+
});
|
|
9506
|
+
this.judging = Promise.race([judgeLeak(decider, s, userSaid), timeout]).then((p) => {
|
|
9507
|
+
this.notify("diag", "leak_judge", { text: s.slice(0, 80), p: Math.round(p * 100) / 100, ms: Math.round(performance.now() - t0) });
|
|
9508
|
+
return p >= LEAK_JUDGE_THRESHOLD;
|
|
9509
|
+
}, (e) => {
|
|
9510
|
+
log20.warn(`leak judge failed \u2014 speaking the sentence: ${e.message}`);
|
|
9511
|
+
return false;
|
|
9512
|
+
}).then((leak) => {
|
|
9513
|
+
clearTimeout(timer);
|
|
9514
|
+
if (gen !== this.turnGen) return;
|
|
9515
|
+
this.judging = void 0;
|
|
9516
|
+
this.leakVerdicts.set(s, leak);
|
|
9517
|
+
this.pumpReflex(false);
|
|
9518
|
+
});
|
|
9519
|
+
return void 0;
|
|
9520
|
+
}
|
|
9521
|
+
static LEAK_JUDGE_TIMEOUT_MS = 1500;
|
|
9522
|
+
/** The stream is done: settle every pending verdict and release everything the gates still hold. */
|
|
9523
|
+
async drainReflex() {
|
|
9524
|
+
for (; ; ) {
|
|
9525
|
+
this.pumpReflex(true);
|
|
9526
|
+
if (!this.judging) return;
|
|
9527
|
+
await this.judging;
|
|
9528
|
+
}
|
|
9529
|
+
}
|
|
9530
|
+
/** Drop any in-flight judge of the current stream (its verdict must not pump a stream that moved on). */
|
|
9531
|
+
dropJudge() {
|
|
9532
|
+
this.turnGen++;
|
|
9533
|
+
this.judging = void 0;
|
|
9534
|
+
}
|
|
9535
|
+
/** Remove a cut analysis leak from this turn's assistant message(s) — left in, it primes the next turn to
|
|
9536
|
+
* leak again and reads as something we said. Keeps any legit prefix; drops a message left empty. */
|
|
9537
|
+
scrubLeakFromTranscript() {
|
|
9538
|
+
const keys = this.leakCuts.splice(0);
|
|
9539
|
+
const t = this.voice.transcript;
|
|
9540
|
+
for (const key of keys) {
|
|
9541
|
+
let i = t.length - 1;
|
|
9542
|
+
while (i >= this.turnBase && !(t[i].role === "assistant" && typeof t[i].content === "string" && t[i].content.includes(key))) i--;
|
|
9543
|
+
if (i < this.turnBase) {
|
|
9544
|
+
log20.warn("analysis leak cut, but its text was not found in the transcript to scrub");
|
|
9545
|
+
continue;
|
|
9546
|
+
}
|
|
9547
|
+
const m = t[i];
|
|
9548
|
+
m.content = m.content.slice(0, m.content.indexOf(key)).trimEnd();
|
|
9549
|
+
if (!m.content && !m.tool_calls?.length) t.splice(i, 1);
|
|
9550
|
+
}
|
|
9551
|
+
}
|
|
9552
|
+
/** A Hold turn is silent BY DESIGN: every reflex text after the Hold is muted (voiceHost drops it), so none of it may
|
|
9553
|
+
* persist either — the transcript is what the agent SAID. Live 20260923-125143 msg 10: the model's reasoning ("We
|
|
9554
|
+
* need to respond according to guidelines… So we should hold") was saved as an assistant reply and fed every later
|
|
9555
|
+
* turn. Strips the text of assistant messages after the Hold call (tool calls stay — they happened); a message left
|
|
9556
|
+
* empty is removed. */
|
|
9557
|
+
scrubHeldText() {
|
|
9558
|
+
if (!this.heldThisTurn) return;
|
|
9559
|
+
const t = this.voice.transcript;
|
|
9560
|
+
let i = this.turnBase;
|
|
9561
|
+
while (i < t.length && !t[i].tool_calls?.some((c) => c.function.name === "Hold")) i++;
|
|
9562
|
+
for (let j = t.length - 1; j > i; j--) {
|
|
9563
|
+
const m = t[j];
|
|
9564
|
+
if (m.role !== "assistant" || !contentText(m.content).trim()) continue;
|
|
9565
|
+
if (m.tool_calls?.length) m.content = "";
|
|
9566
|
+
else t.splice(j, 1);
|
|
9567
|
+
}
|
|
9568
|
+
}
|
|
9569
|
+
/** Shared turn epilogue (user turn, confirmed speculation, re-voice): release held text, scrub a cut leak,
|
|
9570
|
+
* then repair dead air — unless the turn was ABORTED: the user took the floor (barge-in / Esc), which is
|
|
9571
|
+
* not dead air, and a nudge would run on the same aborted signal (live: the "[reminder]" landed in the
|
|
9572
|
+
* transcript as a user turn with no reply, session 20260923-101910 msg 27). */
|
|
9573
|
+
async endTurn(res, fallback) {
|
|
9574
|
+
if (res?.finishReason === "aborted") {
|
|
9575
|
+
this.dropJudge();
|
|
9576
|
+
this.scrubLeakFromTranscript();
|
|
9577
|
+
this.scrubHeldText();
|
|
9578
|
+
return;
|
|
9579
|
+
}
|
|
9580
|
+
await this.drainReflex();
|
|
9581
|
+
this.scrubLeakFromTranscript();
|
|
9582
|
+
this.scrubHeldText();
|
|
9583
|
+
if (this.silentTurn) await this.ackIfSilent(fallback);
|
|
8265
9584
|
}
|
|
8266
9585
|
/** Remove complete stage-direction parentheticals from the UNFORWARDED reflex text (STAGE_DIRECTION_RE).
|
|
8267
9586
|
* Only the unforwarded region is touched — already-spoken audio can't be unsent, and splicing before
|
|
@@ -8283,6 +9602,14 @@ ${nowLine()}. Anchor every relative or time-sensitive reference \u2014 "today",
|
|
|
8283
9602
|
this.reflexBuf = "";
|
|
8284
9603
|
this.reflexForwarded = 0;
|
|
8285
9604
|
this.fabricationCut = false;
|
|
9605
|
+
this.leakCuts = [];
|
|
9606
|
+
this.leakStep = this.leakHit = false;
|
|
9607
|
+
this.stepMarks = [];
|
|
9608
|
+
this.leakVerdicts.clear();
|
|
9609
|
+
this.turnSpoken = "";
|
|
9610
|
+
this.offerOpen = false;
|
|
9611
|
+
this.dropJudge();
|
|
9612
|
+
this.turnBase = this.voice.transcript.length;
|
|
8286
9613
|
this.turnFollowUp = false;
|
|
8287
9614
|
this.voice.options.toolChoice = void 0;
|
|
8288
9615
|
}
|
|
@@ -8294,7 +9621,7 @@ ${nowLine()}. Anchor every relative or time-sensitive reference \u2014 "today",
|
|
|
8294
9621
|
return {
|
|
8295
9622
|
preToolUse: (call) => {
|
|
8296
9623
|
if (this.spec?.state === "pending" && !_DuplexAgent.SPEC_SAFE_TOOLS.has(call.name)) {
|
|
8297
|
-
|
|
9624
|
+
log20.verbose(`speculation aborted: reflex called ${call.name} on unconfirmed input`);
|
|
8298
9625
|
this.abortSpeculation();
|
|
8299
9626
|
return { block: true, reason: "Speculative turn aborted." };
|
|
8300
9627
|
}
|
|
@@ -8328,16 +9655,27 @@ ${nowLine()}. Anchor every relative or time-sensitive reference \u2014 "today",
|
|
|
8328
9655
|
* Wording adapts to whether work was dispatched (an ack) or the inline reply was simply lost. */
|
|
8329
9656
|
async ackIfSilent(fallback) {
|
|
8330
9657
|
const dispatched = this.turnDispatched;
|
|
9658
|
+
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.";
|
|
9659
|
+
const base = this.voice.transcript.length;
|
|
9660
|
+
this.fabricationCut = this.leakStep = false;
|
|
9661
|
+
this.reflexBuf = "";
|
|
9662
|
+
this.reflexForwarded = 0;
|
|
9663
|
+
this.stepMarks = [];
|
|
9664
|
+
this.dropJudge();
|
|
8331
9665
|
this.nudging = true;
|
|
8332
9666
|
try {
|
|
8333
|
-
await this.voice.send(
|
|
9667
|
+
await this.voice.send(reminder);
|
|
8334
9668
|
} catch (e) {
|
|
8335
|
-
|
|
9669
|
+
log20.warn(`ack nudge failed: ${e instanceof Error ? e.message : e}`);
|
|
8336
9670
|
} finally {
|
|
8337
9671
|
this.nudging = false;
|
|
9672
|
+
const i = this.voice.transcript.findIndex((m, k) => k >= base && m.role === "user" && m.content === reminder);
|
|
9673
|
+
if (i >= 0) this.voice.transcript.splice(i, 1);
|
|
9674
|
+
await this.drainReflex();
|
|
9675
|
+
this.scrubLeakFromTranscript();
|
|
8338
9676
|
}
|
|
8339
9677
|
if (!this.spokeThisTurn) {
|
|
8340
|
-
const pool = fallback ? [fallback] : dispatched ? _DuplexAgent.FALLBACK_ACKS : _DuplexAgent.FALLBACK_RETRY;
|
|
9678
|
+
const pool = fallback ? [fallback] : dispatched ? _DuplexAgent.FALLBACK_ACKS : this.leakHit ? _DuplexAgent.FALLBACK_OWN : _DuplexAgent.FALLBACK_RETRY;
|
|
8341
9679
|
const fresh = pool.filter((p) => p !== this.lastFallback);
|
|
8342
9680
|
const line = (fresh.length ? fresh : pool)[Math.floor(Math.random() * (fresh.length || pool.length))];
|
|
8343
9681
|
this.lastFallback = line;
|
|
@@ -8348,26 +9686,134 @@ ${nowLine()}. Anchor every relative or time-sensitive reference \u2014 "today",
|
|
|
8348
9686
|
* hosts/tests key on it. */
|
|
8349
9687
|
static FALLBACK_ACKS = ["Okay, on it.", "On it.", "Alright, working on it."];
|
|
8350
9688
|
static FALLBACK_RETRY = ["Sorry, could you say that again?", "Hm, I missed that \u2014 could you say that again?"];
|
|
9689
|
+
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?"];
|
|
8351
9690
|
lastFallback = "";
|
|
8352
9691
|
/** One user turn: the voice agent streams the reply (and may Act/Think). Serialized with re-voice turns.
|
|
8353
9692
|
* If a speculative reflex call is pending and this content CONFIRMS it (the final matches the
|
|
8354
9693
|
* speculated partial), the in-flight call is adopted as THE turn: its buffered output flushes to the
|
|
8355
9694
|
* host NOW (this is the latency win) and streaming continues live. Any other content aborts the
|
|
8356
|
-
* speculation first (rolled back silently) and runs a normal turn behind it.
|
|
8357
|
-
|
|
8358
|
-
|
|
8359
|
-
|
|
8360
|
-
|
|
8361
|
-
|
|
8362
|
-
|
|
8363
|
-
|
|
9695
|
+
* speculation first (rolled back silently) and runs a normal turn behind it.
|
|
9696
|
+
* `opts.id` names the turn; `opts.replaces` names an earlier one this content REPLACES — the voice engine's
|
|
9697
|
+
* fuller re-finalization of an utterance whose reply had not started ("…previous" → "…previous
|
|
9698
|
+
* conversation."). The replaced turn is skipped if it has not run yet, else aborted and rolled back out of
|
|
9699
|
+
* the transcript (kept only if it already dispatched work) — one reply, to the full text. */
|
|
9700
|
+
send(content, opts) {
|
|
9701
|
+
if (opts?.replaces) this.replaceTurn(opts.replaces);
|
|
9702
|
+
const cur = this.voice.options.signal;
|
|
9703
|
+
const ext = cur && cur === this.turnSignal?.own ? this.turnSignal.ext : cur;
|
|
9704
|
+
const rec = opts?.id ? this.trackTurn(opts.id) : void 0;
|
|
9705
|
+
if (rec?.superseded) {
|
|
9706
|
+
this.notify("diag", "turn_replaced", { id: opts.id, ran: false });
|
|
9707
|
+
return Promise.resolve(_DuplexAgent.replacedResult(this.voice.transcript));
|
|
9708
|
+
}
|
|
9709
|
+
const reflexCut = this.reflexCut;
|
|
9710
|
+
this.reflexCut = false;
|
|
9711
|
+
if (typeof content === "string" && this.hasParkedDelivery()) {
|
|
9712
|
+
const fallback = () => isTrivialBarge(content) ? "resume" : "steer";
|
|
9713
|
+
const accepted = this.offerOpen && this.parkedRedeliver.length > 0 && this.parkedRedeliver.every((p) => p.task === this.offer.task) && (isOfferAcceptance(content) || isTrivialBarge(content));
|
|
9714
|
+
const judge = accepted ? void 0 : this.options.bargeIntent;
|
|
9715
|
+
return (accepted ? Promise.resolve("resume") : judge ? judge(content).catch((e) => {
|
|
9716
|
+
log20.warn(`bargeIntent judge failed \u2014 using the allowlist: ${e.message}`);
|
|
9717
|
+
return fallback();
|
|
9718
|
+
}) : Promise.resolve(fallback())).then((intent) => {
|
|
9719
|
+
this.notify("diag", "barge_intent", { intent, text: content.slice(0, 80) });
|
|
9720
|
+
if (intent === "resume" && !reflexCut) return this.resumeParked();
|
|
9721
|
+
if (intent === "aside" || intent === "resume") {
|
|
9722
|
+
const told = intent === "aside" ? [...this.parkedRedeliver] : [];
|
|
9723
|
+
return this.sendTurn(intent === "aside" ? content + this.asideNote(told) : content, true, rec, ext).then(async (r) => {
|
|
9724
|
+
if (r.finishReason === "aborted" || this.heldThisTurn) return r;
|
|
9725
|
+
const missed = unsaidClauses(told.map((p) => p.text), this.turnSpoken, this.options.decider);
|
|
9726
|
+
const turn = this.turn;
|
|
9727
|
+
this.parkedRedeliver = this.parkedRedeliver.filter((p) => !told.includes(p));
|
|
9728
|
+
await this.resumeParked();
|
|
9729
|
+
void missed.then((m) => {
|
|
9730
|
+
const dropped = m.filter((c) => !isQuestion(c)).join(" ").replace(/[,;]$/, ".");
|
|
9731
|
+
if (!dropped || this.turn !== turn) return;
|
|
9732
|
+
this.parkedRedeliver.unshift({ text: dropped, task: told.at(-1)?.task });
|
|
9733
|
+
this.notify("diag", "aside_dropped", { text: dropped.slice(0, 80) });
|
|
9734
|
+
});
|
|
9735
|
+
return r;
|
|
9736
|
+
});
|
|
9737
|
+
}
|
|
9738
|
+
return this.sendTurn(content, false, rec, ext);
|
|
8364
9739
|
});
|
|
8365
9740
|
}
|
|
9741
|
+
return this.sendTurn(content, false, rec, ext);
|
|
9742
|
+
}
|
|
9743
|
+
/** Host-named user turns (send opts.id), so a later send can replace one. Bounded; a replace that arrives
|
|
9744
|
+
* BEFORE its target (the host's dispatch hops can reorder) leaves a pre-superseded record behind. */
|
|
9745
|
+
userTurns = /* @__PURE__ */ new Map();
|
|
9746
|
+
turnSignal;
|
|
9747
|
+
// the combined signal a named turn installed while it runs
|
|
9748
|
+
trackTurn(id) {
|
|
9749
|
+
let rec = this.userTurns.get(id);
|
|
9750
|
+
if (!rec) {
|
|
9751
|
+
rec = { ctl: new AbortController(), done: false, superseded: false };
|
|
9752
|
+
this.userTurns.set(id, rec);
|
|
9753
|
+
if (this.userTurns.size > 32) this.userTurns.delete(this.userTurns.keys().next().value);
|
|
9754
|
+
}
|
|
9755
|
+
return rec;
|
|
9756
|
+
}
|
|
9757
|
+
replaceTurn(id) {
|
|
9758
|
+
const rec = this.trackTurn(id);
|
|
9759
|
+
if (rec.done) {
|
|
9760
|
+
const drop = rec.dropped;
|
|
9761
|
+
rec.dropped = void 0;
|
|
9762
|
+
if (drop?.length) void this.enqueue(async () => {
|
|
9763
|
+
const t = this.voice.transcript;
|
|
9764
|
+
for (const m of drop) {
|
|
9765
|
+
const i = t.indexOf(m);
|
|
9766
|
+
if (i >= 0) t.splice(i, 1);
|
|
9767
|
+
}
|
|
9768
|
+
this.notify("diag", "turn_replaced", { ran: true, rolledBack: true, late: true });
|
|
9769
|
+
});
|
|
9770
|
+
return;
|
|
9771
|
+
}
|
|
9772
|
+
if (rec.superseded) return;
|
|
9773
|
+
rec.superseded = true;
|
|
9774
|
+
rec.ctl.abort();
|
|
9775
|
+
}
|
|
9776
|
+
static replacedResult(messages) {
|
|
9777
|
+
return { text: "", steps: 0, finishReason: "aborted", messages };
|
|
9778
|
+
}
|
|
9779
|
+
/** A turn replaced mid-run: mute whatever it still holds, and erase it from the transcript (the replacing turn
|
|
9780
|
+
* carries the user's full words) — unless it already dispatched work or SPOKE: the task is real, and the user
|
|
9781
|
+
* heard those words, so both stay visible to the replacing turn. An aborted stream never reaches the transcript,
|
|
9782
|
+
* so what was spoken is recorded as the replaced turn's reply. */
|
|
9783
|
+
dropReplacedTurn() {
|
|
9784
|
+
this.fabricationCut = true;
|
|
9785
|
+
this.reflexBuf = this.reflexBuf.slice(0, this.reflexForwarded);
|
|
9786
|
+
this.dropJudge();
|
|
9787
|
+
const spoke = this.turnSpoken.trim();
|
|
9788
|
+
const rolledBack = !this.turnDispatched && !spoke;
|
|
9789
|
+
if (rolledBack) this.voice.transcript.length = this.turnBase;
|
|
9790
|
+
else if (spoke) {
|
|
9791
|
+
const t = this.voice.transcript;
|
|
9792
|
+
const have = t.slice(this.turnBase).filter((m) => m.role === "assistant").map((m) => contentText(m.content)).join(" ");
|
|
9793
|
+
if (!have.includes(spoke)) t.push({ role: "assistant", content: spoke });
|
|
9794
|
+
}
|
|
9795
|
+
this.notify("diag", "turn_replaced", { ran: true, rolledBack, spoke: !!spoke });
|
|
9796
|
+
return _DuplexAgent.replacedResult(this.voice.transcript);
|
|
9797
|
+
}
|
|
9798
|
+
resumeParked() {
|
|
9799
|
+
if (this.spec) this.abortSpeculation();
|
|
9800
|
+
return this.enqueue(async () => {
|
|
9801
|
+
this.resetTurn();
|
|
9802
|
+
this.redeliverParked();
|
|
9803
|
+
for (const t of this.tasks.values()) if (t.status === "running" && t.deliveryParked === "barge") t.deliveryParked = void 0;
|
|
9804
|
+
return { text: "", steps: 0, finishReason: "stop", messages: [] };
|
|
9805
|
+
});
|
|
9806
|
+
}
|
|
9807
|
+
sendTurn(content, keepParked = false, rec, ext) {
|
|
8366
9808
|
const spec = this.spec;
|
|
8367
9809
|
if (spec?.state === "pending") {
|
|
8368
9810
|
if (typeof content === "string" && speculationConfirms(spec.text, content)) {
|
|
8369
9811
|
spec.state = "confirmed";
|
|
8370
9812
|
spec.finalText = content;
|
|
9813
|
+
if (rec) {
|
|
9814
|
+
spec.rec = rec;
|
|
9815
|
+
rec.ctl.signal.addEventListener("abort", () => spec.ctl.abort(), { once: true });
|
|
9816
|
+
}
|
|
8371
9817
|
for (const ev of spec.buf.splice(0)) this.options.host?.notify?.(ev);
|
|
8372
9818
|
spec.decide("confirm");
|
|
8373
9819
|
this.notify("diag", "speculation_confirmed", { text: spec.text.slice(0, 80) });
|
|
@@ -8379,12 +9825,32 @@ ${nowLine()}. Anchor every relative or time-sensitive reference \u2014 "today",
|
|
|
8379
9825
|
await this.initMemory();
|
|
8380
9826
|
this.resetTurn();
|
|
8381
9827
|
this.turn++;
|
|
8382
|
-
|
|
8383
|
-
|
|
8384
|
-
|
|
8385
|
-
|
|
8386
|
-
|
|
8387
|
-
|
|
9828
|
+
if (!keepParked) {
|
|
9829
|
+
this.parkedRedeliver = [];
|
|
9830
|
+
for (const t of this.tasks.values()) if (t.deliveryParked === "barge") t.deliveryParked = "moved_on";
|
|
9831
|
+
}
|
|
9832
|
+
if (rec?.superseded) {
|
|
9833
|
+
this.notify("diag", "turn_replaced", { ran: false });
|
|
9834
|
+
return _DuplexAgent.replacedResult(this.voice.transcript);
|
|
9835
|
+
}
|
|
9836
|
+
const signal = rec && (ext ? AbortSignal.any([ext, rec.ctl.signal]) : rec.ctl.signal);
|
|
9837
|
+
if (signal) {
|
|
9838
|
+
this.voice.options.signal = signal;
|
|
9839
|
+
this.turnSignal = { own: signal, ext };
|
|
9840
|
+
}
|
|
9841
|
+
let res;
|
|
9842
|
+
try {
|
|
9843
|
+
res = await this.voice.send(content);
|
|
9844
|
+
} finally {
|
|
9845
|
+
if (signal) {
|
|
9846
|
+
this.voice.options.signal = ext;
|
|
9847
|
+
this.turnSignal = void 0;
|
|
9848
|
+
}
|
|
9849
|
+
if (rec) rec.done = true;
|
|
9850
|
+
}
|
|
9851
|
+
if (rec?.superseded) return this.dropReplacedTurn();
|
|
9852
|
+
if (rec && res.finishReason === "aborted" && !this.turnDispatched && !this.turnSpoken.trim()) rec.dropped = this.voice.transcript.slice(this.turnBase);
|
|
9853
|
+
await this.endTurn(res);
|
|
8388
9854
|
return res;
|
|
8389
9855
|
});
|
|
8390
9856
|
}
|
|
@@ -8425,7 +9891,7 @@ ${nowLine()}. Anchor every relative or time-sensitive reference \u2014 "today",
|
|
|
8425
9891
|
try {
|
|
8426
9892
|
res = await this.voice.send(spec.text);
|
|
8427
9893
|
} catch (e) {
|
|
8428
|
-
|
|
9894
|
+
log20.warn(`speculative turn failed: ${e instanceof Error ? e.message : e}`);
|
|
8429
9895
|
} finally {
|
|
8430
9896
|
this.voice.options.signal = prevSignal;
|
|
8431
9897
|
}
|
|
@@ -8439,7 +9905,7 @@ ${nowLine()}. Anchor every relative or time-sensitive reference \u2014 "today",
|
|
|
8439
9905
|
if (d === "abort") {
|
|
8440
9906
|
if (this.voice.transcript.length > base) this.voice.transcript.length = base;
|
|
8441
9907
|
this.speculativeAbortedCalls++;
|
|
8442
|
-
|
|
9908
|
+
log20.verbose(`speculation aborted (${this.speculativeAbortedCalls} total): "${spec.text.slice(0, 50)}"`);
|
|
8443
9909
|
if (this.spec === spec) this.spec = void 0;
|
|
8444
9910
|
return res ?? empty;
|
|
8445
9911
|
}
|
|
@@ -8451,8 +9917,9 @@ ${nowLine()}. Anchor every relative or time-sensitive reference \u2014 "today",
|
|
|
8451
9917
|
}
|
|
8452
9918
|
}
|
|
8453
9919
|
this.spec = void 0;
|
|
8454
|
-
|
|
8455
|
-
if (
|
|
9920
|
+
if (spec.rec) spec.rec.done = true;
|
|
9921
|
+
if (spec.rec?.superseded) return this.dropReplacedTurn();
|
|
9922
|
+
await this.endTurn(res);
|
|
8456
9923
|
return res ?? empty;
|
|
8457
9924
|
});
|
|
8458
9925
|
}
|
|
@@ -8481,37 +9948,125 @@ ${nowLine()}. Anchor every relative or time-sensitive reference \u2014 "today",
|
|
|
8481
9948
|
* superseded topic never talks over the new one (the debt-after-jokes regression). Tasks keep running and
|
|
8482
9949
|
* still fold their result into the transcript — recoverable, just not spoken. Returns parked ids (logging).
|
|
8483
9950
|
* Does NOT cancel. Two callers, two shapes:
|
|
8484
|
-
* - BARGE (default): the user interrupted AUDIO. Park EVERY running task (including this turn's) — the
|
|
8485
|
-
* worker they cut off must stop, whatever turn launched it —
|
|
8486
|
-
*
|
|
8487
|
-
* - SUPERSEDE (`priorTurnsOnly`, `
|
|
8488
|
-
*
|
|
8489
|
-
*
|
|
8490
|
-
parkInFlightDeliveries(
|
|
8491
|
-
if (
|
|
9951
|
+
* - BARGE (default): the user interrupted AUDIO. Park EVERY live running task (including this turn's) — the
|
|
9952
|
+
* worker they cut off must stop, whatever turn launched it — as 'barge', so a following "go on" can bring
|
|
9953
|
+
* it back. A task the user already moved on from stays moved on: a barge never revives it.
|
|
9954
|
+
* - SUPERSEDE (`priorTurnsOnly`, `barge:false`, from dispatch()): a new dispatch redirected to fresh work.
|
|
9955
|
+
* Park only PRIOR-turn live tasks so parallel siblings dispatched in the SAME turn don't silence each other,
|
|
9956
|
+
* as 'moved_on' (don't resurface on a stray "go on"). */
|
|
9957
|
+
parkInFlightDeliveries(barge = true, priorTurnsOnly = false) {
|
|
9958
|
+
if (barge) {
|
|
9959
|
+
this.bargeMark = this.parkedRedeliver.length;
|
|
9960
|
+
this.reflexCut = false;
|
|
9961
|
+
this.offerOpen = false;
|
|
9962
|
+
}
|
|
8492
9963
|
const parked = [];
|
|
8493
9964
|
for (const rec of this.tasks.values())
|
|
8494
9965
|
if (rec.status === "running" && !rec.deliveryParked && (!priorTurnsOnly || rec.createdTurn < this.turn)) {
|
|
8495
|
-
rec.deliveryParked =
|
|
9966
|
+
rec.deliveryParked = barge ? "barge" : "moved_on";
|
|
8496
9967
|
parked.push(rec.id);
|
|
8497
9968
|
}
|
|
8498
9969
|
return parked;
|
|
8499
9970
|
}
|
|
8500
|
-
/**
|
|
8501
|
-
* one
|
|
9971
|
+
/** A barge-in cut a worker delivery the VOICE ENGINE was still speaking — the queued utterances it dropped plus the
|
|
9972
|
+
* unheard rest of the one playing (cli splitCut). It joins the parked delivery AHEAD of whatever parked tasks
|
|
9973
|
+
* produced since the barge (it was voiced first); the user's next line runs the barge-intent judge — resume it,
|
|
9974
|
+
* answer an aside and carry it on, or drop it on a steer/stop. `heard` = the cut utterance's part the user DID hear
|
|
9975
|
+
* (the aside note tells the reflex what was heard vs missed).
|
|
9976
|
+
* Live 20260923-125143: "Oh, that— that's new." cut an iOS-news delivery and its 6 remaining utterances vanished.
|
|
9977
|
+
* `reflexCut` = the barge cut a REFLEX reply (the host's cut had a reply): a "go on" then runs the reflex (see send).
|
|
9978
|
+
* A cut spoken by a task the user moved on from (or whose outcome went to an integration turn) is not parked. */
|
|
9979
|
+
parkCutDelivery(text, reflexCut = false, heard = "") {
|
|
9980
|
+
this.reflexCut = reflexCut;
|
|
9981
|
+
const t = text.trim();
|
|
9982
|
+
if (!t) return;
|
|
9983
|
+
if (this.offer.text && this.offer.text.endsWith(t)) {
|
|
9984
|
+
this.offerOpen = !reflexCut;
|
|
9985
|
+
return;
|
|
9986
|
+
}
|
|
9987
|
+
if (t.split(SENTENCE_SPLIT).every(isQuestion)) return;
|
|
9988
|
+
const first = t.split(/(?<=[.!?])\s/)[0];
|
|
9989
|
+
const task = [...this.voicedBy].reverse().find(([seg]) => t.includes(seg) || first.length >= 16 && seg.includes(first))?.[1];
|
|
9990
|
+
if (task && this.tasks.get(task)?.deliveryParked === "moved_on") return;
|
|
9991
|
+
this.parkedRedeliver.splice(Math.min(this.bargeMark, this.parkedRedeliver.length), 0, { text: t, task, ...heard ? { heard } : {} });
|
|
9992
|
+
this.notify("diag", "delivery_parked", { chars: t.length });
|
|
9993
|
+
}
|
|
9994
|
+
/** One spoken segment of a task's delivery — the ONLY path a worker's speech takes (streamed segments, the settle
|
|
9995
|
+
* tail, the no-spoken fallback). Live → the voice queue; barge-parked → the parked delivery, in order; superseded
|
|
9996
|
+
* (the user moved on) → nowhere (the result still folds into the transcript at settle). */
|
|
9997
|
+
deliverSegment(rec, seg) {
|
|
9998
|
+
if (!seg.trim()) return;
|
|
9999
|
+
if (rec) rec.spokenText = (rec.spokenText ? rec.spokenText + " " : "") + seg;
|
|
10000
|
+
if (!rec?.deliveryParked) {
|
|
10001
|
+
const [say, rest] = rec ? this.budgetSplit(rec, seg) : [seg, ""];
|
|
10002
|
+
if (say) {
|
|
10003
|
+
this.offerOpen = false;
|
|
10004
|
+
this.options.host?.notify?.({ kind: "speak_utterance", message: say });
|
|
10005
|
+
if (rec) {
|
|
10006
|
+
this.voicedBy.set(say.trim(), rec.id);
|
|
10007
|
+
if (this.voicedBy.size > 64) this.voicedBy.delete(this.voicedBy.keys().next().value);
|
|
10008
|
+
}
|
|
10009
|
+
}
|
|
10010
|
+
if (!rest || !rec) return;
|
|
10011
|
+
rec.deliveryParked = "barge";
|
|
10012
|
+
this.parkedRedeliver.push({ text: rest, task: rec.id });
|
|
10013
|
+
if (this.options.spokenOffer) {
|
|
10014
|
+
this.offer = { text: rec.capOffer = this.options.spokenOffer, task: rec.id };
|
|
10015
|
+
this.offerOpen = true;
|
|
10016
|
+
this.options.host?.notify?.({ kind: "speak_utterance", message: this.offer.text });
|
|
10017
|
+
}
|
|
10018
|
+
this.notify("diag", "delivery_budget", { task: rec.id, spoke: rec.voicedChars, parked: rest.length });
|
|
10019
|
+
} else if (rec.deliveryParked === "barge") this.parkedRedeliver.push({ text: seg, task: rec.id });
|
|
10020
|
+
}
|
|
10021
|
+
/** Split a delivery segment at the spoken budget (whole sentences; the first sentence always speaks): what to voice
|
|
10022
|
+
* now, and the rest to park. Marks the budget spent on a cut, so a resumed delivery runs on unbudgeted. */
|
|
10023
|
+
budgetSplit(rec, seg) {
|
|
10024
|
+
const cap = this.options.spokenBudgetChars;
|
|
10025
|
+
if (!cap || rec.budgetSpent) return [seg, ""];
|
|
10026
|
+
const sents = seg.trim().split(SENTENCE_SPLIT);
|
|
10027
|
+
let used = rec.voicedChars ?? 0, n = 0;
|
|
10028
|
+
while (n < sents.length && (!used || used + sents[n].length <= cap)) used += sents[n++].length + 1;
|
|
10029
|
+
const over = sents.slice(n);
|
|
10030
|
+
if (!over.length || over.length === 1 && isQuestion(over[0]) && over[0].length <= OFFER_OVERRUN && used <= cap) {
|
|
10031
|
+
rec.voicedChars = used + (over[0] ? over[0].length + 1 : 0);
|
|
10032
|
+
return [seg, ""];
|
|
10033
|
+
}
|
|
10034
|
+
rec.voicedChars = used;
|
|
10035
|
+
rec.budgetSpent = true;
|
|
10036
|
+
return [sents.slice(0, n).join(" "), sents.slice(n).join(" ")];
|
|
10037
|
+
}
|
|
10038
|
+
/** The cap offer last spoken and the task whose rest it offers — a barge over it is not a delivery to park
|
|
10039
|
+
* (parkCutDelivery). */
|
|
10040
|
+
offer = { text: "", task: "" };
|
|
10041
|
+
/** The cap offer was the last thing voiced (no delivery or turn has spoken since) — a plain yes accepts it (send). */
|
|
10042
|
+
offerOpen = false;
|
|
10043
|
+
/** The task's outcome now belongs to its integration turn (failed / stopped early): take back its parked pieces and
|
|
10044
|
+
* keep it from being revived — a "go on" must not replay raw segments of a result the reflex is about to explain. */
|
|
10045
|
+
dropParked(rec) {
|
|
10046
|
+
this.parkedRedeliver = this.parkedRedeliver.filter((p) => p.task !== rec.id);
|
|
10047
|
+
if (rec.deliveryParked) rec.deliveryParked = "moved_on";
|
|
10048
|
+
}
|
|
10049
|
+
/** True while there is a parked delivery the next line may resume: unheard text already parked, or a barge-parked
|
|
10050
|
+
* task still running (one the user moved on from does not count). */
|
|
8502
10051
|
hasParkedDelivery() {
|
|
8503
|
-
return this.parkedRedeliver.length > 0 || [...this.tasks.values()].some((t) => t.status === "running" && t.deliveryParked);
|
|
8504
|
-
}
|
|
8505
|
-
/**
|
|
8506
|
-
*
|
|
10052
|
+
return this.parkedRedeliver.length > 0 || [...this.tasks.values()].some((t) => t.status === "running" && t.deliveryParked === "barge");
|
|
10053
|
+
}
|
|
10054
|
+
/** The note for an aside over a parked delivery: the same reply-interruption note a cut reflex reply gets
|
|
10055
|
+
* (interruptionNote), over the delivery as it was being said — the cut utterance's heard part, then every unheard
|
|
10056
|
+
* piece. Nothing parked yet (the task is still working) → its result simply follows the answer. */
|
|
10057
|
+
asideNote(told) {
|
|
10058
|
+
const rest = told.map((p) => p.text).join(" ").trim();
|
|
10059
|
+
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.]";
|
|
10060
|
+
const heard = told[0]?.heard ?? "";
|
|
10061
|
+
return interruptionNote({ full: heard + rest, heard }, "aside");
|
|
10062
|
+
}
|
|
10063
|
+
/** Speak the parked delivery now (resume), in order, and clear it. */
|
|
8507
10064
|
redeliverParked() {
|
|
8508
|
-
|
|
8509
|
-
|
|
8510
|
-
|
|
8511
|
-
|
|
8512
|
-
|
|
8513
|
-
}
|
|
8514
|
-
return true;
|
|
10065
|
+
const text = this.parkedRedeliver.splice(0).map((p) => p.text).join(" ").trim();
|
|
10066
|
+
if (!text) return;
|
|
10067
|
+
this.offerOpen = false;
|
|
10068
|
+
this.options.host?.notify?.({ kind: "speak_utterance", message: text });
|
|
10069
|
+
this.notify("diag", "parked_redelivered", { chars: text.length });
|
|
8515
10070
|
}
|
|
8516
10071
|
/** Resolve when all queued voice turns AND all in-flight worker tasks have settled (tests, graceful shutdown). */
|
|
8517
10072
|
async idle() {
|
|
@@ -8567,9 +10122,8 @@ ${nowLine()}. Anchor every relative or time-sensitive reference \u2014 "today",
|
|
|
8567
10122
|
const failed = events.find((e) => /^\[task\b[^\]\n]*\bfailed\b/i.test(e));
|
|
8568
10123
|
this.resetTurn();
|
|
8569
10124
|
this.turnFollowUp = nonCleanTurn;
|
|
8570
|
-
await this.voice.send(events.join("\n"));
|
|
8571
|
-
this.
|
|
8572
|
-
if (this.silentTurn) await this.ackIfSilent(failed ? "Sorry, that didn't work \u2014 the task failed." : void 0);
|
|
10125
|
+
const res = await this.voice.send(events.join("\n"));
|
|
10126
|
+
await this.endTurn(res, failed ? "Sorry, that didn't work \u2014 the task failed." : void 0);
|
|
8573
10127
|
this.notify("revoice_done", "");
|
|
8574
10128
|
});
|
|
8575
10129
|
}
|
|
@@ -8577,13 +10131,14 @@ ${nowLine()}. Anchor every relative or time-sensitive reference \u2014 "today",
|
|
|
8577
10131
|
* Act briefs get a self-verify footer — the worker's report is trusted without review, so it
|
|
8578
10132
|
* must check its own work before reporting (nearly free under prompt caching; measured honest:
|
|
8579
10133
|
* it does NOT fix one-shot logic bugs — see mind/10). Think tasks are pure reasoning — no footer. */
|
|
10134
|
+
/** Static text snapshot of the last `turns` user/assistant messages (`role: text` lines). */
|
|
10135
|
+
recentExcerpt(turns = this.options.excerptTurns) {
|
|
10136
|
+
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");
|
|
10137
|
+
}
|
|
8580
10138
|
buildBrief(brief, tier = "act", deliver = true) {
|
|
8581
|
-
const recent = this.
|
|
10139
|
+
const recent = this.recentExcerpt();
|
|
8582
10140
|
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." : "";
|
|
8583
|
-
const deliverContract = deliver ? `
|
|
8584
|
-
|
|
8585
|
-
## DELIVER (spoken delivery)
|
|
8586
|
-
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." : "") : "";
|
|
10141
|
+
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." : "") : "";
|
|
8587
10142
|
return `${nowLine()}.
|
|
8588
10143
|
|
|
8589
10144
|
` + (recent ? `${brief}
|
|
@@ -8629,12 +10184,7 @@ ${recent}` : brief) + verify + deliverContract;
|
|
|
8629
10184
|
return a || "(no answer from the user \u2014 use your best judgment and note the assumption)";
|
|
8630
10185
|
};
|
|
8631
10186
|
const splitter = new SpokenSplitter();
|
|
8632
|
-
const speak = (seg) =>
|
|
8633
|
-
if (!seg) return;
|
|
8634
|
-
const r = this.tasks.get(id);
|
|
8635
|
-
if (r) r.spokenText = (r.spokenText ? r.spokenText + " " : "") + seg;
|
|
8636
|
-
if (!r?.deliveryParked) o.host?.notify?.({ kind: "speak_utterance", message: seg });
|
|
8637
|
-
};
|
|
10187
|
+
const speak = (seg) => this.deliverSegment(this.tasks.get(id), seg);
|
|
8638
10188
|
const coalescer = new SentenceCoalescer();
|
|
8639
10189
|
const feedSpoken = (s) => {
|
|
8640
10190
|
const ready = coalescer.feed(s);
|
|
@@ -8648,6 +10198,7 @@ ${recent}` : brief) + verify + deliverContract;
|
|
|
8648
10198
|
if (ev?.kind === "text_delta" && typeof ev.message === "string") {
|
|
8649
10199
|
const { spoken, detail } = splitter.feed(ev.message);
|
|
8650
10200
|
feedSpoken(spoken);
|
|
10201
|
+
if (!splitter.inSpoken) flushSpoken();
|
|
8651
10202
|
if (detail.trim()) pushTail(detail.trim());
|
|
8652
10203
|
return;
|
|
8653
10204
|
}
|
|
@@ -8704,7 +10255,7 @@ Another agent just implemented the above. Independently check the CURRENT state
|
|
|
8704
10255
|
this.notify("task_verify", `task ${id}: verifying`, { id });
|
|
8705
10256
|
const cres = await new Agent(checkerOpts).run(checkBrief);
|
|
8706
10257
|
if (cres.finishReason !== "stop") {
|
|
8707
|
-
|
|
10258
|
+
log20.warn(`task ${id}: verify inconclusive (${cres.finishReason})`);
|
|
8708
10259
|
this.notify("task_verify", `task ${id}: verify inconclusive (${cres.finishReason})`, { id, finishReason: cres.finishReason });
|
|
8709
10260
|
}
|
|
8710
10261
|
const sum = (a = 0, b = 0) => a + b;
|
|
@@ -8735,7 +10286,7 @@ Another agent just implemented the above. Independently check the CURRENT state
|
|
|
8735
10286
|
const due = () => {
|
|
8736
10287
|
if (this.pendingAsks.size) return void 0;
|
|
8737
10288
|
const rec = this.tasks.get(id);
|
|
8738
|
-
return rec && rec.status === "running" && Date.now() - lastAt >= this.options.progressIntervalMs ? rec : void 0;
|
|
10289
|
+
return rec && rec.status === "running" && !rec.deliveryParked && Date.now() - lastAt >= this.options.progressIntervalMs ? rec : void 0;
|
|
8739
10290
|
};
|
|
8740
10291
|
const emit = (rec, line, call) => {
|
|
8741
10292
|
lastAt = Date.now();
|
|
@@ -8840,7 +10391,7 @@ Another agent just implemented the above. Independently check the CURRENT state
|
|
|
8840
10391
|
rec.status = "done";
|
|
8841
10392
|
rec.result = res.text;
|
|
8842
10393
|
const incomplete = res.finishReason !== "stop" && res.finishReason !== "empty";
|
|
8843
|
-
|
|
10394
|
+
log20.verbose(`task ${id} done (${res.steps} steps${incomplete ? `, INCOMPLETE: ${res.finishReason}` : ""})`);
|
|
8844
10395
|
this.notify("task_done", `task ${id} (${rec.label}) completed`, {
|
|
8845
10396
|
id,
|
|
8846
10397
|
text: res.text,
|
|
@@ -8852,46 +10403,39 @@ Another agent just implemented the above. Independently check the CURRENT state
|
|
|
8852
10403
|
});
|
|
8853
10404
|
if (incomplete) {
|
|
8854
10405
|
if (this.foldIfSuperseded(rec, res.text)) return;
|
|
10406
|
+
this.dropParked(rec);
|
|
8855
10407
|
return this.queueRevoice(this.integrationPrompt(rec, "incomplete", res.text, res.finishReason), true);
|
|
8856
10408
|
}
|
|
8857
10409
|
const tail = rec.splitter?.flush();
|
|
8858
|
-
if (tail?.spoken
|
|
8859
|
-
if (res.text.trim()) this.
|
|
8860
|
-
if (
|
|
8861
|
-
|
|
8862
|
-
|
|
8863
|
-
if (this.awaitTrivialRedeliver) {
|
|
8864
|
-
this.awaitTrivialRedeliver = false;
|
|
8865
|
-
this.options.host?.notify?.({ kind: "speak_utterance", message: gist });
|
|
8866
|
-
this.notify("diag", "parked_redelivered", { chars: gist.length });
|
|
8867
|
-
} else this.parkedRedeliver.push(gist);
|
|
8868
|
-
}
|
|
8869
|
-
}
|
|
8870
|
-
if (!rec.splitter?.spokeAny && res.text.trim() && !rec.deliveryParked)
|
|
8871
|
-
this.options.host?.notify?.({ kind: "speak_utterance", message: res.text });
|
|
10410
|
+
if (tail?.spoken) this.deliverSegment(rec, tail.spoken);
|
|
10411
|
+
if (!rec.splitter?.spokeAny && res.text.trim()) this.deliverSegment(rec, res.text);
|
|
10412
|
+
if (res.text.trim()) this.voice.transcript.push({ role: "assistant", content: res.text + (rec.capOffer ? `
|
|
10413
|
+
|
|
10414
|
+
(Said aloud in place of the rest of the spoken summary: "${rec.capOffer}")` : "") });
|
|
8872
10415
|
}
|
|
8873
10416
|
onWorkerFailed(id, err2) {
|
|
8874
10417
|
this.failTask(this.tasks.get(id), err2 instanceof Error ? err2.message : String(err2));
|
|
8875
10418
|
}
|
|
8876
|
-
/** A delivery the user has SUPERSEDED —
|
|
10419
|
+
/** A delivery the user has SUPERSEDED — moved on from (deliveryParked 'moved_on', set by a redirect) —
|
|
8877
10420
|
* must not hijack the voice when it settles late (partial or failed): that would talk over the new topic
|
|
8878
10421
|
* and, worse, auto-escalate work the user already abandoned. Fold a recoverable note into the transcript
|
|
8879
10422
|
* instead (the reflex can surface it if asked) and report true so the caller skips queueRevoice. A merely
|
|
8880
|
-
* barge-parked task (
|
|
10423
|
+
* barge-parked task ('barge' — a "go on" may still resume it) is NOT superseded: it
|
|
8881
10424
|
* keeps the normal surfacing path so a failure the user still cares about is not swallowed. */
|
|
8882
10425
|
foldIfSuperseded(rec, note) {
|
|
8883
|
-
if (
|
|
10426
|
+
if (rec.deliveryParked !== "moved_on") return false;
|
|
8884
10427
|
if (note.trim()) this.voice.transcript.push({ role: "assistant", content: note });
|
|
8885
|
-
|
|
10428
|
+
log20.verbose(`task ${rec.id} settled but superseded (${rec.status}) \u2014 folded into transcript, not voiced`);
|
|
8886
10429
|
return true;
|
|
8887
10430
|
}
|
|
8888
10431
|
failTask(rec, msg) {
|
|
8889
10432
|
this.dropAsk(rec.id);
|
|
8890
10433
|
rec.status = "error";
|
|
8891
10434
|
rec.result = msg;
|
|
8892
|
-
|
|
10435
|
+
log20.warn(`task ${rec.id} failed: ${msg}`);
|
|
8893
10436
|
this.notify("task_error", `task ${rec.id} (${rec.label}) failed: ${msg}`);
|
|
8894
10437
|
if (this.foldIfSuperseded(rec, `The "${rec.label}" task failed: ${msg}`)) return;
|
|
10438
|
+
this.dropParked(rec);
|
|
8895
10439
|
this.queueRevoice(this.integrationPrompt(rec, "error", msg, "error"), true);
|
|
8896
10440
|
}
|
|
8897
10441
|
// --- voice tools (closures over this instance) ---
|
|
@@ -8920,10 +10464,18 @@ Another agent just implemented the above. Independently check the CURRENT state
|
|
|
8920
10464
|
this.notify("task_started", `task ${id} (${lbl}) started`, { id, brief, tier });
|
|
8921
10465
|
return id;
|
|
8922
10466
|
}
|
|
10467
|
+
/** Tool result for a dispatch. The task is ALREADY RUNNING — live, the terse "Acknowledge briefly" drew
|
|
10468
|
+
* "Did you want me to look up today's news?" right after dispatching exactly that (session 20260923-101910
|
|
10469
|
+
* msg 18). Also truthful about delivery: a clean result is spoken by the worker itself and lands in
|
|
10470
|
+
* context (TaskStatus returns it); only failures come back as a [task … failed] event. */
|
|
10471
|
+
dispatchResult(id, verb) {
|
|
10472
|
+
const after = `The worker speaks its result to the user itself when done (TaskStatus ${id} returns it).`;
|
|
10473
|
+
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}`;
|
|
10474
|
+
}
|
|
8923
10475
|
actTool() {
|
|
8924
10476
|
return {
|
|
8925
10477
|
name: "Act",
|
|
8926
|
-
description: 'Escalate real work (reading/editing files, searching, running tasks, building) to a standard background worker. Returns immediately with a task id; the result
|
|
10478
|
+
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).',
|
|
8927
10479
|
parameters: {
|
|
8928
10480
|
type: "object",
|
|
8929
10481
|
required: ["brief"],
|
|
@@ -8938,7 +10490,7 @@ Another agent just implemented the above. Independently check the CURRENT state
|
|
|
8938
10490
|
this.turnBriefs.add(String(brief ?? ""));
|
|
8939
10491
|
this.voice.options.toolChoice = "none";
|
|
8940
10492
|
const id = await this.dispatch(String(brief ?? ""), "act", label ? String(label) : void 0, this.turnFollowUp);
|
|
8941
|
-
return this.
|
|
10493
|
+
return this.dispatchResult(id, "Acting on");
|
|
8942
10494
|
}
|
|
8943
10495
|
};
|
|
8944
10496
|
}
|
|
@@ -8960,7 +10512,7 @@ Another agent just implemented the above. Independently check the CURRENT state
|
|
|
8960
10512
|
this.turnBriefs.add(String(brief ?? ""));
|
|
8961
10513
|
this.voice.options.toolChoice = "none";
|
|
8962
10514
|
const id = await this.dispatch(String(brief ?? ""), "think", label ? String(label) : void 0, this.turnFollowUp);
|
|
8963
|
-
return this.
|
|
10515
|
+
return this.dispatchResult(id, "Thinking on");
|
|
8964
10516
|
}
|
|
8965
10517
|
};
|
|
8966
10518
|
}
|
|
@@ -8972,7 +10524,13 @@ Another agent just implemented the above. Independently check the CURRENT state
|
|
|
8972
10524
|
run: async ({ id }) => {
|
|
8973
10525
|
const list = id ? [this.tasks.get(String(id))].filter(Boolean) : [...this.tasks.values()];
|
|
8974
10526
|
if (!list.length) return id ? `No task '${id}'.` : "No background tasks.";
|
|
8975
|
-
|
|
10527
|
+
const cap = id ? 1500 : 300;
|
|
10528
|
+
const clip = (x) => x.length > cap ? `${x.slice(0, cap)}\u2026 (truncated)` : x;
|
|
10529
|
+
return list.map((t) => {
|
|
10530
|
+
const head = `${t.id} (${t.label}): ${t.status}`;
|
|
10531
|
+
const body = (t.status === "done" ? t.spokenText?.trim() || t.result?.trim() : t.status === "error" ? t.result?.trim() : "") ?? "";
|
|
10532
|
+
return body ? `${head}. ${t.status === "error" ? "Error" : "Result"}: ${clip(body)}` : head;
|
|
10533
|
+
}).join("\n");
|
|
8976
10534
|
}
|
|
8977
10535
|
};
|
|
8978
10536
|
}
|
|
@@ -8984,18 +10542,20 @@ Another agent just implemented the above. Independently check the CURRENT state
|
|
|
8984
10542
|
const kinds = [.../* @__PURE__ */ new Set(["time", "branch", "ls", "file", "capabilities", ...Object.keys(this.options.quickLook ?? {})])];
|
|
8985
10543
|
return {
|
|
8986
10544
|
name: "QuickLook",
|
|
8987
|
-
description: `Instant read-only lookup \u2014 one of: ${kinds.join(", ")}. For trivial facts only; anything needing search, commands, or reasoning goes through Act
|
|
10545
|
+
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.",
|
|
8988
10546
|
parameters: {
|
|
8989
10547
|
type: "object",
|
|
8990
10548
|
required: ["what"],
|
|
8991
10549
|
properties: {
|
|
8992
10550
|
what: { type: "string", enum: kinds, description: "what to look up" },
|
|
8993
|
-
path: { type: "string", description:
|
|
10551
|
+
path: { type: "string", description: 'for ls/file: the path to look at; for sessions: a session id (or "previous")' }
|
|
8994
10552
|
}
|
|
8995
10553
|
},
|
|
8996
10554
|
run: async ({ what, path }) => {
|
|
8997
10555
|
const fs = this.options.fs;
|
|
8998
10556
|
try {
|
|
10557
|
+
const sessions = this.options.quickLook?.sessions;
|
|
10558
|
+
if (sessions && (what === "file" || what === "ls") && SESSION_ID_RE.test(String(path ?? "").trim())) return await sessions(String(path).trim());
|
|
8999
10559
|
const over = this.options.quickLook?.[String(what)];
|
|
9000
10560
|
if (over) return await over(path ? String(path) : void 0);
|
|
9001
10561
|
switch (String(what)) {
|
|
@@ -9103,6 +10663,250 @@ Another agent just implemented the above. Independently check the CURRENT state
|
|
|
9103
10663
|
}
|
|
9104
10664
|
};
|
|
9105
10665
|
|
|
10666
|
+
// src/modelRouter.ts
|
|
10667
|
+
init_llm();
|
|
10668
|
+
init_logging();
|
|
10669
|
+
var log21 = forComponent("ModelRouter");
|
|
10670
|
+
var ModelRouterOptions = class {
|
|
10671
|
+
classifier = jevClassifier();
|
|
10672
|
+
/** Capability tiers → model id (any provider). fast/standard run the main session; premium is only ever an offload. */
|
|
10673
|
+
models = { fast: "anthropic/claude-haiku-4-5-20251001", standard: "anthropic/claude-sonnet-4-6", premium: "anthropic/claude-opus-5-5" };
|
|
10674
|
+
/** Escalate to a stronger tier when it holds at least this much probability mass (upward bias). */
|
|
10675
|
+
threshold = 0.25;
|
|
10676
|
+
/** P(same thread) at or above which the previous premium side-conversation is resumed. */
|
|
10677
|
+
sameThreadAt = 0.5;
|
|
10678
|
+
classifyTimeoutMs = 1e4;
|
|
10679
|
+
/** Host-supplied pricing (model, usage) → USD, so mixed-model turns are costed per model. */
|
|
10680
|
+
costOf = () => 0;
|
|
10681
|
+
/** Step budget for the premium sub-agent. */
|
|
10682
|
+
offloadMaxSteps = 40;
|
|
10683
|
+
/** Host-supplied provider options per model (e.g. claude-code's subscription env); applied on every tier switch + the offload child. */
|
|
10684
|
+
providerOptionsFor;
|
|
10685
|
+
};
|
|
10686
|
+
function pickTier(c, th) {
|
|
10687
|
+
const o = c.probs.premium ?? 0, s = c.probs.standard ?? 0;
|
|
10688
|
+
if (o >= th || c.choice === "premium") return "premium";
|
|
10689
|
+
if (c.choice === "standard" || s + o >= th) return "standard";
|
|
10690
|
+
return "fast";
|
|
10691
|
+
}
|
|
10692
|
+
function textTurns(msgs) {
|
|
10693
|
+
const out = [];
|
|
10694
|
+
for (const m of msgs) {
|
|
10695
|
+
if (m.role === "user") {
|
|
10696
|
+
const t = contentText(m.content).trim();
|
|
10697
|
+
if (t) out.push({ user: t, assistant: "" });
|
|
10698
|
+
} else if (m.role === "assistant" && out.length) {
|
|
10699
|
+
const t = contentText(m.content).trim();
|
|
10700
|
+
if (t) out[out.length - 1].assistant = (out[out.length - 1].assistant + "\n" + t).trim();
|
|
10701
|
+
}
|
|
10702
|
+
}
|
|
10703
|
+
return out;
|
|
10704
|
+
}
|
|
10705
|
+
var fmtTurns = (ts) => ts.map((t) => `USER: ${t.user}
|
|
10706
|
+
ASSISTANT: ${t.assistant || "(no text reply)"}`).join("\n\n");
|
|
10707
|
+
var addUsage = (a, b) => {
|
|
10708
|
+
if (!a) return b;
|
|
10709
|
+
if (!b) return a;
|
|
10710
|
+
return {
|
|
10711
|
+
promptTokens: a.promptTokens + b.promptTokens,
|
|
10712
|
+
completionTokens: a.completionTokens + b.completionTokens,
|
|
10713
|
+
totalTokens: a.totalTokens + b.totalTokens,
|
|
10714
|
+
cacheCreationTokens: (a.cacheCreationTokens ?? 0) + (b.cacheCreationTokens ?? 0),
|
|
10715
|
+
cacheReadTokens: (a.cacheReadTokens ?? 0) + (b.cacheReadTokens ?? 0)
|
|
10716
|
+
};
|
|
10717
|
+
};
|
|
10718
|
+
var ModelRouter = class {
|
|
10719
|
+
constructor(options) {
|
|
10720
|
+
this.options = options;
|
|
10721
|
+
this.options = { ...new ModelRouterOptions(), ...options };
|
|
10722
|
+
}
|
|
10723
|
+
options;
|
|
10724
|
+
state = {};
|
|
10725
|
+
get o() {
|
|
10726
|
+
return this.options;
|
|
10727
|
+
}
|
|
10728
|
+
/** Classify one turn. Never throws: classifier failure/timeout ⇒ standard. */
|
|
10729
|
+
async decide(transcript, next) {
|
|
10730
|
+
const o = this.o;
|
|
10731
|
+
const main2 = this.state.main === o.models.fast ? "fast" : "standard";
|
|
10732
|
+
const input = {
|
|
10733
|
+
previousModel: main2,
|
|
10734
|
+
history: textTurns(transcript).slice(-4).map((t) => ({ user: t.user.slice(0, 1500), assistant: t.assistant.slice(0, 1500) })),
|
|
10735
|
+
next,
|
|
10736
|
+
lastOffload: this.state.offload?.question
|
|
10737
|
+
};
|
|
10738
|
+
const t0 = performance.now();
|
|
10739
|
+
let d;
|
|
10740
|
+
try {
|
|
10741
|
+
let timer;
|
|
10742
|
+
const c = await Promise.race([
|
|
10743
|
+
o.classifier(input),
|
|
10744
|
+
new Promise((_, rej) => {
|
|
10745
|
+
timer = setTimeout(() => rej(new Error(`classifier timeout ${o.classifyTimeoutMs}ms`)), o.classifyTimeoutMs);
|
|
10746
|
+
})
|
|
10747
|
+
]).finally(() => clearTimeout(timer));
|
|
10748
|
+
const tier = pickTier(c, o.threshold);
|
|
10749
|
+
d = { tier, model: o.models[tier], probs: c.probs, choice: c.choice, sameThread: c.sameThread, classifyMs: Math.round(performance.now() - t0) };
|
|
10750
|
+
if (tier === "premium") d.offload = this.state.offload && (c.sameThread ?? 0) >= o.sameThreadAt ? "continue" : "fresh";
|
|
10751
|
+
} catch (e) {
|
|
10752
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
10753
|
+
log21.warn(`classifier failed, falling back to standard: ${msg}`);
|
|
10754
|
+
d = { tier: "standard", model: o.models.standard, probs: {}, fallback: msg, classifyMs: Math.round(performance.now() - t0) };
|
|
10755
|
+
}
|
|
10756
|
+
log21.info(`route ${d.tier}${d.offload ? `(${d.offload})` : ""} \xB7 argmax=${d.choice ?? "-"} \xB7 p=${JSON.stringify(d.probs)}${d.sameThread != null ? ` \xB7 same=${d.sameThread.toFixed(2)}` : ""} \xB7 ${d.classifyMs}ms`);
|
|
10757
|
+
return d;
|
|
10758
|
+
}
|
|
10759
|
+
/** Run one user turn on `agent` with routing. Returns a RunResult carrying `costUsd` + `routing`. */
|
|
10760
|
+
async send(agent, content) {
|
|
10761
|
+
const o = this.o;
|
|
10762
|
+
const text = contentText(content);
|
|
10763
|
+
const d = await this.decide(agent.transcript, text);
|
|
10764
|
+
if (d.tier !== "premium") {
|
|
10765
|
+
this.use(agent, this.state.main = d.model);
|
|
10766
|
+
const res = await agent.send(content);
|
|
10767
|
+
return { ...res, costUsd: o.costOf(d.model, res.usage), routing: d };
|
|
10768
|
+
}
|
|
10769
|
+
this.use(agent, this.state.main ??= o.models.standard);
|
|
10770
|
+
return this.offload(agent, content, text, d);
|
|
10771
|
+
}
|
|
10772
|
+
async offload(agent, content, text, d) {
|
|
10773
|
+
const o = this.o, main2 = this.state.main;
|
|
10774
|
+
const prev = this.state.offload;
|
|
10775
|
+
const cont = d.offload === "continue" && prev && prev.mainLen <= agent.transcript.length;
|
|
10776
|
+
let briefUsage, prompt;
|
|
10777
|
+
if (cont) {
|
|
10778
|
+
const delta = textTurns(agent.transcript.slice(prev.mainLen));
|
|
10779
|
+
prompt = `Follow-up in the same thread.${delta.length ? ` Since your last answer the main conversation continued:
|
|
10780
|
+
|
|
10781
|
+
${fmtTurns(delta)}
|
|
10782
|
+
` : ""}
|
|
10783
|
+
|
|
10784
|
+
New request:
|
|
10785
|
+
${text}`;
|
|
10786
|
+
} else {
|
|
10787
|
+
const b = await this.brief(agent, text);
|
|
10788
|
+
briefUsage = b.usage;
|
|
10789
|
+
const last2 = textTurns(agent.transcript).slice(-2);
|
|
10790
|
+
prompt = `You are handling a hard turn delegated by the main agent of an ongoing session. You do NOT see its conversation \u2014 only this brief. Do the work with your tools, then reply to the user directly (your reply is shown as the session's answer).
|
|
10791
|
+
|
|
10792
|
+
--- brief from the main agent ---
|
|
10793
|
+
${b.text}
|
|
10794
|
+
|
|
10795
|
+
${last2.length ? `--- last turns (verbatim) ---
|
|
10796
|
+
${fmtTurns(last2)}
|
|
10797
|
+
|
|
10798
|
+
` : ""}--- current user request (verbatim) ---
|
|
10799
|
+
${text}`;
|
|
10800
|
+
}
|
|
10801
|
+
const child = this.spawn(agent);
|
|
10802
|
+
const res = cont ? (child.transcript = prev.messages, await child.send(prompt)) : await child.run(prompt);
|
|
10803
|
+
this.state.offloads = (this.state.offloads ?? 0) + 1;
|
|
10804
|
+
if (cont) this.state.reuses = (this.state.reuses ?? 0) + 1;
|
|
10805
|
+
const answer = res.text || `(premium sub-agent finished with no reply; finishReason=${res.finishReason})`;
|
|
10806
|
+
agent.transcript.push({ role: "user", content }, { role: "assistant", content: answer });
|
|
10807
|
+
this.state.offload = { messages: child.transcript, mainLen: agent.transcript.length, question: text };
|
|
10808
|
+
log21.info(`offload ${cont ? "continue" : "fresh"} done \xB7 ${res.finishReason} \xB7 ${res.steps} steps`);
|
|
10809
|
+
const costUsd = o.costOf(main2, briefUsage) + o.costOf(o.models.premium, res.usage);
|
|
10810
|
+
return { ...res, text: answer, messages: agent.transcript, usage: addUsage(briefUsage, res.usage), costUsd, routing: d };
|
|
10811
|
+
}
|
|
10812
|
+
/** The main model writes the task brief on its own (cached) context, tools off; the transcript is restored after. */
|
|
10813
|
+
async brief(agent, text) {
|
|
10814
|
+
const snap = agent.transcript.slice();
|
|
10815
|
+
const { host, toolChoice, maxSteps } = agent.options;
|
|
10816
|
+
Object.assign(agent.options, { host: void 0, toolChoice: "none", maxSteps: 1 });
|
|
10817
|
+
try {
|
|
10818
|
+
const r = await agent.send(`[router] The next request is being delegated to a stronger sub-agent that will NOT see this conversation. Write the self-contained brief you would hand it (<=200 words): the task, the relevant facts/files/decisions established so far, constraints. Output only the brief.
|
|
10819
|
+
|
|
10820
|
+
The request:
|
|
10821
|
+
${text}`);
|
|
10822
|
+
return { text: r.text.trim() || text, usage: r.usage };
|
|
10823
|
+
} catch (e) {
|
|
10824
|
+
log21.warn(`brief failed, sending the raw request: ${e instanceof Error ? e.message : String(e)}`);
|
|
10825
|
+
return { text };
|
|
10826
|
+
} finally {
|
|
10827
|
+
agent.transcript = snap;
|
|
10828
|
+
Object.assign(agent.options, { host, toolChoice, maxSteps });
|
|
10829
|
+
}
|
|
10830
|
+
}
|
|
10831
|
+
use(agent, model) {
|
|
10832
|
+
agent.options.model = model;
|
|
10833
|
+
if (this.o.providerOptionsFor) agent.options.providerOptions = this.o.providerOptionsFor(model);
|
|
10834
|
+
}
|
|
10835
|
+
spawn(agent) {
|
|
10836
|
+
const a = agent.options;
|
|
10837
|
+
const co = childOptionsFor(
|
|
10838
|
+
{ ai: a.ai, model: a.model, fs: a.fs, tools: a.tools, systemPrompt: a.systemPrompt, hooks: a.hooks, maxSteps: this.o.offloadMaxSteps, traceDir: a.traceDir, providerOptionsFor: this.o.providerOptionsFor },
|
|
10839
|
+
a.fs,
|
|
10840
|
+
a.depth ?? 0,
|
|
10841
|
+
a.maxDepth ?? 2,
|
|
10842
|
+
void 0,
|
|
10843
|
+
this.o.models.premium
|
|
10844
|
+
);
|
|
10845
|
+
if (typeof co === "string") throw new Error(co);
|
|
10846
|
+
co.signal = a.signal;
|
|
10847
|
+
return new Agent(co);
|
|
10848
|
+
}
|
|
10849
|
+
};
|
|
10850
|
+
var JEV_KEYS = ["AI_GATEWAY_API_KEY", "OPENROUTER_API_KEY", "TYPESAFE_API_KEY"];
|
|
10851
|
+
async function loadJev(opts) {
|
|
10852
|
+
const mod = await import(opts.jevModule ?? process.env.AGENTX_JEV_MODULE ?? "@livx.cc/mcp-jev/jev");
|
|
10853
|
+
const env = { ...process.env };
|
|
10854
|
+
const file = opts.envFile ?? process.env.AGENTX_JEV_ENV;
|
|
10855
|
+
if (file) {
|
|
10856
|
+
const { readFileSync: readFileSync10 } = await import("fs");
|
|
10857
|
+
for (const l of readFileSync10(file, "utf8").split("\n")) {
|
|
10858
|
+
const m = l.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*?)\s*$/);
|
|
10859
|
+
if (m && !env[m[1]]) env[m[1]] = m[2].replace(/^['"]|['"]$/g, "");
|
|
10860
|
+
}
|
|
10861
|
+
}
|
|
10862
|
+
return { mod, env };
|
|
10863
|
+
}
|
|
10864
|
+
async function jevAvailable(opts = {}) {
|
|
10865
|
+
try {
|
|
10866
|
+
const { env } = await loadJev(opts);
|
|
10867
|
+
return JEV_KEYS.some((k) => env[k]?.trim());
|
|
10868
|
+
} catch (e) {
|
|
10869
|
+
log21.debug(`jev unavailable: ${e instanceof Error ? e.message : String(e)}`);
|
|
10870
|
+
return false;
|
|
10871
|
+
}
|
|
10872
|
+
}
|
|
10873
|
+
function jevClassifier(opts = {}) {
|
|
10874
|
+
let router;
|
|
10875
|
+
const load = async () => {
|
|
10876
|
+
const { mod, env } = await loadJev(opts);
|
|
10877
|
+
return new mod.JevRouter({ env });
|
|
10878
|
+
};
|
|
10879
|
+
return async (input) => {
|
|
10880
|
+
const jev = await (router ??= load().catch((e) => {
|
|
10881
|
+
router = void 0;
|
|
10882
|
+
throw e;
|
|
10883
|
+
}));
|
|
10884
|
+
const questions = [{
|
|
10885
|
+
id: "tier",
|
|
10886
|
+
type: "choice",
|
|
10887
|
+
instructions: "Pick the CHEAPEST model that will handle the NEXT assistant turn correctly for this coding-agent conversation. Staying on previousModel keeps its prompt cache warm; switch only when the next turn clearly needs more (or much less) capability.",
|
|
10888
|
+
criteria: {
|
|
10889
|
+
fast: "Trivial: commands, lookups, acknowledgements, commits, mechanical single-file edits, short rewrites.",
|
|
10890
|
+
standard: "Standard engineering: features, bounded refactors, reviews, tests, following a clear trace, summarising.",
|
|
10891
|
+
premium: "Hard: root-causing subtle/intermittent bugs, concurrency, security semantics, architecture trade-offs, quantitative reasoning."
|
|
10892
|
+
}
|
|
10893
|
+
}];
|
|
10894
|
+
if (input.lastOffload) questions.push({
|
|
10895
|
+
id: "sameThread",
|
|
10896
|
+
type: "boolean",
|
|
10897
|
+
instructions: "Does nextUserMessage continue the same line of work as lastOffload (the previous hard request, handled by a specialist) \u2014 a follow-up, refinement or next step of it \u2014 rather than a new, unrelated topic?"
|
|
10898
|
+
});
|
|
10899
|
+
const state = { previousModel: input.previousModel, history: input.history, nextUserMessage: input.next, ...input.lastOffload ? { lastOffload: input.lastOffload } : {} };
|
|
10900
|
+
const r = await jev.evaluate({ state, questions }, opts.provider);
|
|
10901
|
+
const a = r.answers.tier;
|
|
10902
|
+
const map = (k) => k;
|
|
10903
|
+
const probs = {};
|
|
10904
|
+
for (const [k, v] of Object.entries(a.probabilities ?? {})) probs[map(k)] = v;
|
|
10905
|
+
const st = r.answers.sameThread;
|
|
10906
|
+
return { choice: map(a.choice), probs, sameThread: st ? st.probability ?? (st.value ? 1 : 0) : void 0 };
|
|
10907
|
+
};
|
|
10908
|
+
}
|
|
10909
|
+
|
|
9106
10910
|
// src/mcp.ts
|
|
9107
10911
|
function toResult(result) {
|
|
9108
10912
|
if (result == null) return { text: "" };
|
|
@@ -9290,7 +11094,7 @@ import { MemFilesystem as MemFilesystem3, IndexedDbFilesystem, CommandExecutor a
|
|
|
9290
11094
|
init_logging();
|
|
9291
11095
|
import { spawn } from "child_process";
|
|
9292
11096
|
import { createHash } from "crypto";
|
|
9293
|
-
var
|
|
11097
|
+
var log22 = forComponent("mcp");
|
|
9294
11098
|
var PROTOCOL_VERSION = "2025-06-18";
|
|
9295
11099
|
var DEFAULT_TIMEOUT_MS = 3e4;
|
|
9296
11100
|
var DEFAULT_CALL_TIMEOUT_MS = 30 * 6e4;
|
|
@@ -9320,7 +11124,7 @@ var StdioTransport = class {
|
|
|
9320
11124
|
proc.stdout.setEncoding("utf8");
|
|
9321
11125
|
proc.stdout.on("data", (chunk) => this.onData(chunk));
|
|
9322
11126
|
proc.stderr.setEncoding("utf8");
|
|
9323
|
-
proc.stderr.on("data", (chunk) =>
|
|
11127
|
+
proc.stderr.on("data", (chunk) => log22.debug(`[${command}] stderr:`, chunk.trimEnd()));
|
|
9324
11128
|
proc.on("exit", (code) => this.failAll(new Error(`MCP server "${command}" exited (code ${code})`)));
|
|
9325
11129
|
proc.on("error", (e) => this.failAll(e instanceof Error ? e : new Error(String(e))));
|
|
9326
11130
|
}
|
|
@@ -9334,7 +11138,7 @@ var StdioTransport = class {
|
|
|
9334
11138
|
try {
|
|
9335
11139
|
this.dispatch(JSON.parse(line));
|
|
9336
11140
|
} catch (e) {
|
|
9337
|
-
|
|
11141
|
+
log22.debug("dropping non-JSON line from MCP server:", line, e);
|
|
9338
11142
|
}
|
|
9339
11143
|
}
|
|
9340
11144
|
}
|
|
@@ -9344,7 +11148,7 @@ var StdioTransport = class {
|
|
|
9344
11148
|
try {
|
|
9345
11149
|
p2?.onProgress?.(msg.params ?? {});
|
|
9346
11150
|
} catch (e) {
|
|
9347
|
-
|
|
11151
|
+
log22.debug("onProgress handler threw", e);
|
|
9348
11152
|
}
|
|
9349
11153
|
return;
|
|
9350
11154
|
}
|
|
@@ -9382,7 +11186,7 @@ var StdioTransport = class {
|
|
|
9382
11186
|
const onAbort = () => {
|
|
9383
11187
|
if (!this.pending.delete(id)) return;
|
|
9384
11188
|
clearTimeout(timer);
|
|
9385
|
-
this.notify("notifications/cancelled", { requestId: id, reason: String(signal?.reason?.message ?? "cancelled") }).catch((e) =>
|
|
11189
|
+
this.notify("notifications/cancelled", { requestId: id, reason: String(signal?.reason?.message ?? "cancelled") }).catch((e) => log22.debug("cancel notify failed", e));
|
|
9386
11190
|
reject(abortError(signal));
|
|
9387
11191
|
};
|
|
9388
11192
|
const done = () => signal?.removeEventListener("abort", onAbort);
|
|
@@ -9406,7 +11210,7 @@ var StdioTransport = class {
|
|
|
9406
11210
|
try {
|
|
9407
11211
|
this.proc?.stdin?.end();
|
|
9408
11212
|
} catch (e) {
|
|
9409
|
-
|
|
11213
|
+
log22.debug("stdin end failed", e);
|
|
9410
11214
|
}
|
|
9411
11215
|
this.proc?.kill();
|
|
9412
11216
|
}
|
|
@@ -9441,7 +11245,7 @@ var HttpTransport = class {
|
|
|
9441
11245
|
return await this.post({ jsonrpc: "2.0", id, method, params: opts.onProgress ? withProgressToken(params, id) : params }, false, signal, opts.onProgress);
|
|
9442
11246
|
} catch (e) {
|
|
9443
11247
|
if (opts.signal?.aborted) {
|
|
9444
|
-
void this.notify("notifications/cancelled", { requestId: id, reason: String(opts.signal.reason?.message ?? "cancelled") }).catch((err2) =>
|
|
11248
|
+
void this.notify("notifications/cancelled", { requestId: id, reason: String(opts.signal.reason?.message ?? "cancelled") }).catch((err2) => log22.debug("cancel notify failed", err2));
|
|
9445
11249
|
throw abortError(opts.signal);
|
|
9446
11250
|
}
|
|
9447
11251
|
throw e;
|
|
@@ -9484,7 +11288,7 @@ function sseMessage(line) {
|
|
|
9484
11288
|
try {
|
|
9485
11289
|
return JSON.parse(trimmed.slice(5).trim());
|
|
9486
11290
|
} catch (e) {
|
|
9487
|
-
|
|
11291
|
+
log22.debug("skipping unparseable SSE data line", e);
|
|
9488
11292
|
return void 0;
|
|
9489
11293
|
}
|
|
9490
11294
|
}
|
|
@@ -9494,7 +11298,7 @@ async function readSseResponse(resp, id, onProgress) {
|
|
|
9494
11298
|
try {
|
|
9495
11299
|
onProgress?.(obj.params ?? {});
|
|
9496
11300
|
} catch (e) {
|
|
9497
|
-
|
|
11301
|
+
log22.debug("onProgress handler threw", e);
|
|
9498
11302
|
}
|
|
9499
11303
|
return void 0;
|
|
9500
11304
|
}
|
|
@@ -9593,7 +11397,7 @@ async function mountWithDeadline(name, cfg, mountTimeoutMs) {
|
|
|
9593
11397
|
return { name, client, tools, specs, serverInfo: init?.serverInfo, config: cfg };
|
|
9594
11398
|
})(), mountTimeoutMs, name);
|
|
9595
11399
|
} catch (e) {
|
|
9596
|
-
await client.close().catch((err2) =>
|
|
11400
|
+
await client.close().catch((err2) => log22.debug(`close after failed mount of "${name}": ${err2}`));
|
|
9597
11401
|
throw e;
|
|
9598
11402
|
}
|
|
9599
11403
|
}
|
|
@@ -9604,15 +11408,15 @@ function validEntries(servers) {
|
|
|
9604
11408
|
return Object.entries(servers).filter(([name, cfg]) => {
|
|
9605
11409
|
if (!cfg || cfg.disabled) return false;
|
|
9606
11410
|
if (!cfg.command && !cfg.url) {
|
|
9607
|
-
|
|
11411
|
+
log22.warn(`MCP server "${name}" needs a command (stdio) or url (http) \u2014 skipping`);
|
|
9608
11412
|
return false;
|
|
9609
11413
|
}
|
|
9610
11414
|
return true;
|
|
9611
11415
|
});
|
|
9612
11416
|
}
|
|
9613
11417
|
function logMountFailure(name, e) {
|
|
9614
|
-
if (e instanceof McpAuthError)
|
|
9615
|
-
else
|
|
11418
|
+
if (e instanceof McpAuthError) log22.warn(`MCP "${name}" needs-auth: HTTP ${e.status} \u2014 set bearerToken or headers in its config; skipping`);
|
|
11419
|
+
else log22.error(`MCP server "${name}" failed to mount: ${e?.message ?? e}`);
|
|
9616
11420
|
}
|
|
9617
11421
|
async function mountMcpServers(servers = {}, opts = {}) {
|
|
9618
11422
|
const entries = validEntries(servers);
|
|
@@ -9622,7 +11426,7 @@ async function mountMcpServers(servers = {}, opts = {}) {
|
|
|
9622
11426
|
const name = entries[i][0];
|
|
9623
11427
|
if (r.status === "fulfilled") {
|
|
9624
11428
|
out.push(r.value);
|
|
9625
|
-
|
|
11429
|
+
log22.debug(`MCP "${name}" mounted \u2014 ${r.value.tools.length} tool(s)${r.value.serverInfo?.name ? ` from ${r.value.serverInfo.name}` : ""}`);
|
|
9626
11430
|
} else logMountFailure(name, r.reason);
|
|
9627
11431
|
});
|
|
9628
11432
|
return out;
|
|
@@ -9662,7 +11466,7 @@ var McpPool = class {
|
|
|
9662
11466
|
const prev = this.warm.get(key);
|
|
9663
11467
|
if (prev) {
|
|
9664
11468
|
clearTimeout(prev.timer);
|
|
9665
|
-
if (prev.client !== client) void prev.client.close().catch((err2) =>
|
|
11469
|
+
if (prev.client !== client) void prev.client.close().catch((err2) => log22.debug(`warm-pool replace close failed: ${err2}`));
|
|
9666
11470
|
}
|
|
9667
11471
|
const e = { client, timer: void 0 };
|
|
9668
11472
|
this.warm.set(key, e);
|
|
@@ -9679,7 +11483,7 @@ var McpPool = class {
|
|
|
9679
11483
|
const e = this.warm.get(key);
|
|
9680
11484
|
if (!e) return;
|
|
9681
11485
|
this.warm.delete(key);
|
|
9682
|
-
await e.client.close().catch((err2) =>
|
|
11486
|
+
await e.client.close().catch((err2) => log22.debug(`warm-pool evict close failed: ${err2}`));
|
|
9683
11487
|
}
|
|
9684
11488
|
async closeAll() {
|
|
9685
11489
|
for (const e of this.warm.values()) {
|
|
@@ -9897,7 +11701,7 @@ init_tools_shell();
|
|
|
9897
11701
|
// src/tools.notify.ts
|
|
9898
11702
|
init_logging();
|
|
9899
11703
|
import { execFile } from "child_process";
|
|
9900
|
-
var
|
|
11704
|
+
var log24 = forComponent("notify");
|
|
9901
11705
|
function makeNotifyTool(opts = {}) {
|
|
9902
11706
|
const platform2 = opts.platform ?? process.platform;
|
|
9903
11707
|
const run = opts.exec ?? execFile;
|
|
@@ -9921,7 +11725,7 @@ function makeNotifyTool(opts = {}) {
|
|
|
9921
11725
|
return new Promise((resolve5) => {
|
|
9922
11726
|
run(argv[0], argv[1], { timeout: 5e3 }, (e) => {
|
|
9923
11727
|
if (e) {
|
|
9924
|
-
|
|
11728
|
+
log24.debug("notification failed", e);
|
|
9925
11729
|
resolve5(`Notification failed: ${e.message}`);
|
|
9926
11730
|
} else resolve5("Notification shown.");
|
|
9927
11731
|
});
|
|
@@ -9937,7 +11741,7 @@ import { BodDB as BodDB2 } from "@bod.ee/db";
|
|
|
9937
11741
|
init_logging();
|
|
9938
11742
|
import { existsSync as existsSync2, readFileSync as readFileSync4 } from "fs";
|
|
9939
11743
|
import { homedir } from "os";
|
|
9940
|
-
var
|
|
11744
|
+
var log25 = forComponent("cli-util");
|
|
9941
11745
|
function dotDirs(base, sub, opts = {}) {
|
|
9942
11746
|
const home = opts.home ?? homedir();
|
|
9943
11747
|
const dirs = [`${base}/.agent/${sub}`, `${base}/.claude/${sub}`, `${home}/.agent/${sub}`, `${home}/.claude/${sub}`];
|
|
@@ -9954,7 +11758,7 @@ function parseJson(text, fallback, what = "json") {
|
|
|
9954
11758
|
try {
|
|
9955
11759
|
return JSON.parse(text);
|
|
9956
11760
|
} catch (e) {
|
|
9957
|
-
|
|
11761
|
+
log25.debug(`parseJson(${what}) failed: ${e.message}`);
|
|
9958
11762
|
return fallback;
|
|
9959
11763
|
}
|
|
9960
11764
|
}
|
|
@@ -9964,7 +11768,7 @@ function readJsonFile(path, fallback) {
|
|
|
9964
11768
|
try {
|
|
9965
11769
|
text = readFileSync4(path, "utf8");
|
|
9966
11770
|
} catch (e) {
|
|
9967
|
-
|
|
11771
|
+
log25.debug(`readJsonFile(${path}) unreadable: ${e.message}`);
|
|
9968
11772
|
return fallback;
|
|
9969
11773
|
}
|
|
9970
11774
|
return parseJson(text, fallback, path);
|
|
@@ -10263,7 +12067,7 @@ import { existsSync as existsSync4, mkdirSync as mkdirSync5, readFileSync as rea
|
|
|
10263
12067
|
import { homedir as homedir3 } from "os";
|
|
10264
12068
|
import { dirname as dirname3, join as join6 } from "path";
|
|
10265
12069
|
import { fileURLToPath } from "url";
|
|
10266
|
-
var
|
|
12070
|
+
var log26 = forComponent("VoiceIO");
|
|
10267
12071
|
var now3 = () => performance.now();
|
|
10268
12072
|
var Player = class {
|
|
10269
12073
|
proc = null;
|
|
@@ -10277,7 +12081,7 @@ var Player = class {
|
|
|
10277
12081
|
["-loglevel", "quiet", "-nodisp", "-fflags", "nobuffer", "-flags", "low_delay", "-probesize", "32", "-f", "s16le", "-ar", String(TTS_SAMPLE_RATE), "-ch_layout", "mono", "-i", "-"],
|
|
10278
12082
|
{ stdio: ["pipe", "ignore", "ignore"] }
|
|
10279
12083
|
);
|
|
10280
|
-
this.proc.on("error", (e) =>
|
|
12084
|
+
this.proc.on("error", (e) => log26.warn(`ffplay error: ${e.message}`));
|
|
10281
12085
|
this.proc.stdin.on("error", () => {
|
|
10282
12086
|
});
|
|
10283
12087
|
this.bytesWritten = 0;
|
|
@@ -10315,7 +12119,7 @@ function detectFfmpegMic() {
|
|
|
10315
12119
|
const devices = [...audio.matchAll(/\[(\d+)\] (.+)/g)].map(([, idx, name]) => ({ idx, name: name.trim() }));
|
|
10316
12120
|
const mic = devices.find((d) => /microphone|built-in/i.test(d.name) && !/teams|blackhole|loopback/i.test(d.name)) ?? devices[0];
|
|
10317
12121
|
if (!mic) throw new Error("no audio input device found");
|
|
10318
|
-
|
|
12122
|
+
log26.debug(`ffmpeg mic: [${mic.idx}] ${mic.name}`);
|
|
10319
12123
|
return `:${mic.idx}`;
|
|
10320
12124
|
}
|
|
10321
12125
|
function detectedInputDevice() {
|
|
@@ -10351,15 +12155,15 @@ function resolveAecBinary() {
|
|
|
10351
12155
|
if (existsSync4(bin) && statSync4(bin).mtimeMs >= statSync4(src).mtimeMs) return bin;
|
|
10352
12156
|
if (spawnSync2("which", ["swiftc"]).status !== 0) return null;
|
|
10353
12157
|
mkdirSync5(cacheDir, { recursive: true });
|
|
10354
|
-
|
|
12158
|
+
log26.info("compiling AEC mic helper (first run)\u2026");
|
|
10355
12159
|
const build = spawnSync2("swiftc", ["-O", "-o", bin, src, "-Xlinker", "-sectcreate", "-Xlinker", "__TEXT", "-Xlinker", "__info_plist", "-Xlinker", plist], { encoding: "utf8" });
|
|
10356
12160
|
if (build.status !== 0) {
|
|
10357
|
-
|
|
12161
|
+
log26.warn(`AEC build failed: ${build.stderr?.slice(0, 400)}`);
|
|
10358
12162
|
return null;
|
|
10359
12163
|
}
|
|
10360
12164
|
const sign = spawnSync2("codesign", ["-fs", "-", bin], { encoding: "utf8" });
|
|
10361
12165
|
if (sign.status !== 0) {
|
|
10362
|
-
|
|
12166
|
+
log26.warn(`codesign failed: ${sign.stderr?.slice(0, 200)}`);
|
|
10363
12167
|
return null;
|
|
10364
12168
|
}
|
|
10365
12169
|
return bin;
|
|
@@ -10401,16 +12205,16 @@ var NodeMicSource = class {
|
|
|
10401
12205
|
this.proc = spawn2(this.bin, [], { stdio: ["ignore", "pipe", "ignore"] });
|
|
10402
12206
|
} else {
|
|
10403
12207
|
if (spawnSync2("which", ["ffmpeg"]).status !== 0) throw new Error("voice I/O unavailable: no AEC helper and no ffmpeg on PATH");
|
|
10404
|
-
|
|
12208
|
+
log26.info("mic: raw capture (no AEC) \u2014 echo handled heuristically; headphones recommended");
|
|
10405
12209
|
this.proc = spawn2(
|
|
10406
12210
|
"ffmpeg",
|
|
10407
12211
|
["-loglevel", "error", "-f", "avfoundation", "-i", detectFfmpegMic(), "-ar", String(STT_SAMPLE_RATE), "-ac", "1", "-f", "s16le", "-"],
|
|
10408
12212
|
{ stdio: ["ignore", "pipe", "pipe"] }
|
|
10409
12213
|
);
|
|
10410
|
-
this.proc.stderr.on("data", (d) =>
|
|
12214
|
+
this.proc.stderr.on("data", (d) => log26.warn(`ffmpeg: ${String(d).trim()}`));
|
|
10411
12215
|
}
|
|
10412
12216
|
this.proc.on("exit", (c) => {
|
|
10413
|
-
if (c && !this.stopped)
|
|
12217
|
+
if (c && !this.stopped) log26.error(`mic capture exited (${c}) \u2014 check mic permission / MIC_DEVICE / MIC_AEC=0`);
|
|
10414
12218
|
});
|
|
10415
12219
|
this.proc.stdout.on("data", (chunk) => onChunk(chunk));
|
|
10416
12220
|
}
|
|
@@ -10476,7 +12280,7 @@ var AecDuplexAudio = class {
|
|
|
10476
12280
|
this.proc.stdin.on("error", () => {
|
|
10477
12281
|
});
|
|
10478
12282
|
this.proc.on("exit", (c) => {
|
|
10479
|
-
if (c && !this.stopped)
|
|
12283
|
+
if (c && !this.stopped) log26.error(`aec duplex audio exited (${c}) \u2014 check mic permission / MIC_AEC=0`);
|
|
10480
12284
|
});
|
|
10481
12285
|
this.proc.stdout.on("data", (chunk) => {
|
|
10482
12286
|
this.gotChunk = true;
|
|
@@ -10492,7 +12296,7 @@ var AecDuplexAudio = class {
|
|
|
10492
12296
|
openMicSettings();
|
|
10493
12297
|
this.onFatal?.("microphone permission denied \u2014 enable it in System Settings \u2192 Privacy & Security \u2192 Microphone for your terminal, then restart it");
|
|
10494
12298
|
}
|
|
10495
|
-
} else
|
|
12299
|
+
} else log26.debug(`mic-aec: ${s}`);
|
|
10496
12300
|
}
|
|
10497
12301
|
});
|
|
10498
12302
|
if (!this.noVpio && !this.triedFallback) {
|
|
@@ -10501,7 +12305,7 @@ var AecDuplexAudio = class {
|
|
|
10501
12305
|
this.triedFallback = true;
|
|
10502
12306
|
this.noVpio = true;
|
|
10503
12307
|
this._aec = false;
|
|
10504
|
-
|
|
12308
|
+
log26.warn("mic-aec: VPIO delivered no audio in 2.5s \u2014 falling back to non-VPIO capture (no AEC \u2192 half-duplex, no barge-in)");
|
|
10505
12309
|
this.onDegrade?.();
|
|
10506
12310
|
this.killProc();
|
|
10507
12311
|
this.spawnHelper();
|
|
@@ -10605,6 +12409,78 @@ var matchVoiceCommand = (text) => {
|
|
|
10605
12409
|
const t = text.toLowerCase().replace(/[^a-z ]/g, " ").replace(/\s+/g, " ").trim().replace(/^(?:hey|ok|okay|please) /, "").replace(/ (?:please|now)$/, "");
|
|
10606
12410
|
return /^(voice off|mute|stop listening)( \1)*$/.test(t) ? "off" : null;
|
|
10607
12411
|
};
|
|
12412
|
+
var replacedVoiceTurn = (last, meta) => meta?.supersedes !== void 0 && last?.text === meta.supersedes ? last : void 0;
|
|
12413
|
+
var CutInAbsorber = class {
|
|
12414
|
+
cut = "";
|
|
12415
|
+
absorbed;
|
|
12416
|
+
interjected(heard) {
|
|
12417
|
+
this.cut = heard;
|
|
12418
|
+
this.absorbed = void 0;
|
|
12419
|
+
}
|
|
12420
|
+
absorbs(text, meta) {
|
|
12421
|
+
const cut = this.cut || (meta?.supersedes !== void 0 && meta.supersedes === this.absorbed?.text ? this.absorbed.cut : "");
|
|
12422
|
+
this.cut = "";
|
|
12423
|
+
this.absorbed = void 0;
|
|
12424
|
+
const words2 = (s) => s.trim().split(/\s+/).filter(Boolean).length;
|
|
12425
|
+
if (!cut || words2(text) - words2(cut) >= 3) return false;
|
|
12426
|
+
this.absorbed = { text, cut };
|
|
12427
|
+
return true;
|
|
12428
|
+
}
|
|
12429
|
+
};
|
|
12430
|
+
function splitCut(cut) {
|
|
12431
|
+
if (!cut) return { reply: null, delivery: "", heard: "" };
|
|
12432
|
+
const queued = cut.queued ?? [];
|
|
12433
|
+
if (!cut.queuedTurn) return { reply: cut.full ? cut : null, delivery: queued.join(" ").trim(), heard: "" };
|
|
12434
|
+
let at = cut.full.length;
|
|
12435
|
+
if (cut.full.length - cut.heard.length > 12) {
|
|
12436
|
+
const sentence = Math.max(...[". ", "! ", "? "].map((b) => cut.heard.lastIndexOf(b)));
|
|
12437
|
+
at = cut.exact ? cut.heard.length : sentence < 0 ? 0 : sentence + 2;
|
|
12438
|
+
}
|
|
12439
|
+
const heard = cut.full.slice(0, at).trim();
|
|
12440
|
+
return { reply: null, delivery: [cut.full.slice(at), ...queued].map((x) => x.trim()).filter(Boolean).join(" "), heard: heard && heard + " " };
|
|
12441
|
+
}
|
|
12442
|
+
function takeCut(v, forVoiceLine) {
|
|
12443
|
+
const cut = v?.takeInterruptedReply() ?? null;
|
|
12444
|
+
return splitCut(forVoiceLine ? cut : null);
|
|
12445
|
+
}
|
|
12446
|
+
function bindOverlapInstinct(v, instinct, live, decideMs = 900) {
|
|
12447
|
+
const o = v.options;
|
|
12448
|
+
const { onDiag } = o;
|
|
12449
|
+
o.overlapDecideMs = decideMs;
|
|
12450
|
+
instinct.options.overlapSettleMs = 0;
|
|
12451
|
+
o.onDiag = (ev) => {
|
|
12452
|
+
onDiag(ev);
|
|
12453
|
+
if (!live()) return;
|
|
12454
|
+
if (ev.kind === "overlap_pause") void instinct.overlap(String(ev.text ?? ""));
|
|
12455
|
+
else if (/^(?:barge_in|overlap_resume|overlap_end|interrupt|tts_turn_begin)$/.test(ev.kind)) instinct.overlapEnded();
|
|
12456
|
+
};
|
|
12457
|
+
Object.assign(instinct.options, {
|
|
12458
|
+
onYield: () => {
|
|
12459
|
+
if (!live()) return false;
|
|
12460
|
+
if (v.decideOverlap("yield")) return;
|
|
12461
|
+
if (!v.audible) return false;
|
|
12462
|
+
v.interrupt();
|
|
12463
|
+
o.onBargeIn("speaking");
|
|
12464
|
+
},
|
|
12465
|
+
// `heard` = what Jev judged: the engine absorbs only those words — a reaction that grows past them is re-decided.
|
|
12466
|
+
onKeepTalking: (heard) => live() ? v.decideOverlap("keep_talking", heard) : false
|
|
12467
|
+
});
|
|
12468
|
+
}
|
|
12469
|
+
function bindAckInstinct(v, instinct) {
|
|
12470
|
+
const o = v.options;
|
|
12471
|
+
const { onDiag } = o;
|
|
12472
|
+
o.pickAck = (c) => instinct.pickAck(c);
|
|
12473
|
+
instinct.options.ackSettleMs = 2 * o.adaptiveAckMs + 200;
|
|
12474
|
+
o.onDiag = (ev) => {
|
|
12475
|
+
try {
|
|
12476
|
+
if (ev.kind === "ack_fired") instinct.ackOutcome(String(ev.phrase));
|
|
12477
|
+
else if (ev.kind === "ack_skipped" || ev.kind === "ack_cancelled") instinct.ackOutcome(null);
|
|
12478
|
+
} catch (e) {
|
|
12479
|
+
log26.warn(`ack outcome tap failed: ${e instanceof Error ? e.message : e}`);
|
|
12480
|
+
}
|
|
12481
|
+
onDiag(ev);
|
|
12482
|
+
};
|
|
12483
|
+
}
|
|
10608
12484
|
var VoiceIOOptions = class extends VoiceEngineOptions {
|
|
10609
12485
|
sonioxApiKey = process.env.SONIOX_API_KEY ?? "";
|
|
10610
12486
|
cartesiaApiKey = process.env.CARTESIA_API_KEY ?? "";
|
|
@@ -10721,7 +12597,7 @@ function fakeVoiceParts(uttFile) {
|
|
|
10721
12597
|
init_logging();
|
|
10722
12598
|
import { appendFile, mkdirSync as mkdirSync6 } from "fs";
|
|
10723
12599
|
import { dirname as dirname4 } from "path";
|
|
10724
|
-
var
|
|
12600
|
+
var log27 = forComponent("VoiceDiag");
|
|
10725
12601
|
var JsonlDiagSink = class {
|
|
10726
12602
|
constructor(path) {
|
|
10727
12603
|
this.path = path;
|
|
@@ -10762,7 +12638,7 @@ var JsonlDiagSink = class {
|
|
|
10762
12638
|
disable(e) {
|
|
10763
12639
|
this.disabled = true;
|
|
10764
12640
|
this.queue.length = 0;
|
|
10765
|
-
|
|
12641
|
+
log27.debug(`voice diag sink disabled (${this.path}): ${e instanceof Error ? e.message : e}`);
|
|
10766
12642
|
}
|
|
10767
12643
|
/** True once a failure permanently disabled the sink (tests/inspection). */
|
|
10768
12644
|
get isDisabled() {
|
|
@@ -10832,7 +12708,7 @@ async function loadConfig(cwd) {
|
|
|
10832
12708
|
|
|
10833
12709
|
// cli/hooks-config.ts
|
|
10834
12710
|
import { spawnSync as spawnSync3 } from "child_process";
|
|
10835
|
-
var
|
|
12711
|
+
var log28 = forComponent("hooks");
|
|
10836
12712
|
var escapeRegex = (s) => s.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
10837
12713
|
function ruleMatches(rule, toolName) {
|
|
10838
12714
|
if (!rule.tool || rule.tool === "*") return true;
|
|
@@ -10849,7 +12725,7 @@ function runCmd(rule, env) {
|
|
|
10849
12725
|
});
|
|
10850
12726
|
return { code: r.status ?? 1, out: ((r.stdout ?? "") + (r.stderr ?? "")).trim() };
|
|
10851
12727
|
} catch (e) {
|
|
10852
|
-
|
|
12728
|
+
log28.debug(`hook command failed: ${rule.command}`, e);
|
|
10853
12729
|
return { code: 1, out: String(e?.message ?? e) };
|
|
10854
12730
|
}
|
|
10855
12731
|
}
|
|
@@ -10953,10 +12829,10 @@ function formatDiff(ops, opts = {}) {
|
|
|
10953
12829
|
}
|
|
10954
12830
|
|
|
10955
12831
|
// cli/session.ts
|
|
10956
|
-
import { existsSync as existsSync6, mkdirSync as mkdirSync7, readFileSync as readFileSync7, writeFileSync as writeFileSync4, readdirSync, renameSync, symlinkSync as symlinkSync2, unlinkSync, readlinkSync } from "fs";
|
|
12832
|
+
import { existsSync as existsSync6, mkdirSync as mkdirSync7, readFileSync as readFileSync7, writeFileSync as writeFileSync4, readdirSync, renameSync, symlinkSync as symlinkSync2, unlinkSync, readlinkSync, statSync as statSync5 } from "fs";
|
|
10957
12833
|
import { homedir as homedir5 } from "os";
|
|
10958
12834
|
import { join as join8 } from "path";
|
|
10959
|
-
var
|
|
12835
|
+
var log29 = forComponent("session");
|
|
10960
12836
|
var globalDir = () => join8(homedir5(), ".agent", "sessions");
|
|
10961
12837
|
var SessionStore = class {
|
|
10962
12838
|
dir;
|
|
@@ -11001,7 +12877,7 @@ var SessionStore = class {
|
|
|
11001
12877
|
}
|
|
11002
12878
|
load(id) {
|
|
11003
12879
|
if (!this.safeId(id)) {
|
|
11004
|
-
|
|
12880
|
+
log29.debug(`rejecting unsafe session id: ${id}`);
|
|
11005
12881
|
return void 0;
|
|
11006
12882
|
}
|
|
11007
12883
|
const path = join8(this.dir, `${id}.json`);
|
|
@@ -11009,7 +12885,7 @@ var SessionStore = class {
|
|
|
11009
12885
|
try {
|
|
11010
12886
|
return JSON.parse(readFileSync7(path, "utf8"));
|
|
11011
12887
|
} catch (e) {
|
|
11012
|
-
|
|
12888
|
+
log29.debug(`unreadable session ${id} \u2014 ignoring`, e);
|
|
11013
12889
|
return void 0;
|
|
11014
12890
|
}
|
|
11015
12891
|
}
|
|
@@ -11022,11 +12898,26 @@ var SessionStore = class {
|
|
|
11022
12898
|
try {
|
|
11023
12899
|
metas.push(JSON.parse(readFileSync7(join8(this.dir, f), "utf8")).meta);
|
|
11024
12900
|
} catch (e) {
|
|
11025
|
-
|
|
12901
|
+
log29.debug(`skipping unreadable session file ${f}`, e);
|
|
11026
12902
|
}
|
|
11027
12903
|
}
|
|
11028
12904
|
return metas.sort((a, b) => b.updated - a.updated);
|
|
11029
12905
|
}
|
|
12906
|
+
/** Cheap listing — ids from filenames + mtime/size from stat, NO parsing (list() parses every file: 870
|
|
12907
|
+
* sessions / 157MB took ~150ms+ synchronously). Most-recently-written first. */
|
|
12908
|
+
files() {
|
|
12909
|
+
if (!existsSync6(this.dir)) return [];
|
|
12910
|
+
const out = [];
|
|
12911
|
+
for (const f of readdirSync(this.dir)) {
|
|
12912
|
+
if (!f.endsWith(".json")) continue;
|
|
12913
|
+
try {
|
|
12914
|
+
const st = statSync5(join8(this.dir, f));
|
|
12915
|
+
out.push({ id: f.slice(0, -5), mtime: st.mtimeMs, size: st.size });
|
|
12916
|
+
} catch {
|
|
12917
|
+
}
|
|
12918
|
+
}
|
|
12919
|
+
return out.sort((a, b) => b.mtime - a.mtime);
|
|
12920
|
+
}
|
|
11030
12921
|
latest() {
|
|
11031
12922
|
return this.list()[0];
|
|
11032
12923
|
}
|
|
@@ -11087,6 +12978,61 @@ function titleOf(messages) {
|
|
|
11087
12978
|
const t = contentText(u?.content).replace(/\s+/g, " ").trim();
|
|
11088
12979
|
return t.length > 60 ? t.slice(0, 57) + "\u2026" : t || "(empty)";
|
|
11089
12980
|
}
|
|
12981
|
+
function recapLine(m) {
|
|
12982
|
+
if (m.role !== "user" && m.role !== "assistant") return "";
|
|
12983
|
+
let t = contentText(m.content);
|
|
12984
|
+
if (m.role === "user" && /^\[(?:session started|reminder)\]/.test(t)) return "";
|
|
12985
|
+
const spoken = [...t.matchAll(/<spoken>([\s\S]*?)<\/spoken>/g)].map((x) => x[1]).join(" ");
|
|
12986
|
+
if (spoken) t = spoken;
|
|
12987
|
+
t = t.replace(/\s*\[the user interrupted you[\s\S]*$/, "").replace(/\[[a-z_ ]+\]\s*/gi, "").replace(/\s+/g, " ").trim();
|
|
12988
|
+
return t ? `${m.role}: ${t}` : "";
|
|
12989
|
+
}
|
|
12990
|
+
function sessionRecap(store, currentId, which, cap = 2500, maxParseBytes = 4e6) {
|
|
12991
|
+
const others = store.files().filter((f) => f.id !== currentId);
|
|
12992
|
+
if (!others.length) return "No earlier sessions in this project.";
|
|
12993
|
+
const created = (id) => {
|
|
12994
|
+
const m = id.match(/^(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})(\d{2})/);
|
|
12995
|
+
return m ? new Date(+m[1], +m[2] - 1, +m[3], +m[4], +m[5], +m[6]).getTime() : NaN;
|
|
12996
|
+
};
|
|
12997
|
+
const when = (ts) => {
|
|
12998
|
+
if (!Number.isFinite(ts)) return "unknown time";
|
|
12999
|
+
const d = new Date(ts), today = (/* @__PURE__ */ new Date()).toDateString() === d.toDateString();
|
|
13000
|
+
return `${today ? "today" : d.toLocaleDateString("en-US", { month: "short", day: "numeric" })} ${d.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" })}`;
|
|
13001
|
+
};
|
|
13002
|
+
const load = (f) => f.size <= maxParseBytes ? store.load(f.id) : void 0;
|
|
13003
|
+
const w = which?.trim();
|
|
13004
|
+
if (!w) {
|
|
13005
|
+
const rows = others.slice(0, 5).map((f) => {
|
|
13006
|
+
const data2 = load(f);
|
|
13007
|
+
const asks = (data2?.messages ?? []).filter((m) => m.role === "user").map(recapLine).filter(Boolean).slice(0, 3).map((l) => `"${l.slice(6, 86)}"`);
|
|
13008
|
+
return `- ${f.id} (${when(created(f.id))}${data2 ? `, ${data2.meta.turns} turns` : ""})${asks.length ? `: ${asks.join("; ")}` : ""}`;
|
|
13009
|
+
});
|
|
13010
|
+
return `Earlier sessions (newest first) \u2014 to read one, call QuickLook with what:"sessions" and path:"<id>":
|
|
13011
|
+
${rows.join("\n")}`;
|
|
13012
|
+
}
|
|
13013
|
+
const recent = others.slice(0, 5).map((f) => f.id).join(", ");
|
|
13014
|
+
let hit;
|
|
13015
|
+
if (/^(latest|last|previous|prev)$/i.test(w)) {
|
|
13016
|
+
const cur = created(currentId);
|
|
13017
|
+
hit = others.filter((f) => created(f.id) < cur).sort((a, b) => created(b.id) - created(a.id))[0] ?? others[0];
|
|
13018
|
+
} else {
|
|
13019
|
+
hit = others.find((f) => f.id === w);
|
|
13020
|
+
if (!hit) {
|
|
13021
|
+
const pre = others.filter((f) => f.id.startsWith(w));
|
|
13022
|
+
if (pre.length > 1) return `'${w}' matches ${pre.length} sessions \u2014 use the full id: ${pre.slice(0, 5).map((f) => f.id).join(", ")}`;
|
|
13023
|
+
hit = pre[0];
|
|
13024
|
+
}
|
|
13025
|
+
}
|
|
13026
|
+
if (!hit) return `No session '${w}'. Recent: ${recent}`;
|
|
13027
|
+
const data = load(hit);
|
|
13028
|
+
if (!data) return `Session ${hit.id} is too large (or unreadable) to recap here \u2014 ask a worker (Act) to read it.`;
|
|
13029
|
+
let body = data.messages.map(recapLine).filter(Boolean).map((l) => l.length > 600 ? `${l.slice(0, 600)}\u2026` : l).join("\n");
|
|
13030
|
+
if (body.length > cap) body = `\u2026(earlier lines omitted)
|
|
13031
|
+
${body.slice(-cap)}`;
|
|
13032
|
+
return `Session ${data.meta.id} (${when(data.meta.created)}):
|
|
13033
|
+
${body || "(no conversation)"}
|
|
13034
|
+
(That is everything recorded. Where it does not say why something was done or what was meant, say you do not know \u2014 never supply a reason.)`;
|
|
13035
|
+
}
|
|
11090
13036
|
|
|
11091
13037
|
// cli/checkpoints.ts
|
|
11092
13038
|
var MUTATORS = /* @__PURE__ */ new Set(["Write", "Edit", "MultiEdit", "ApplyEdits"]);
|
|
@@ -11191,7 +13137,7 @@ import { promisify } from "util";
|
|
|
11191
13137
|
import { writeFileSync as writeFileSync5, mkdirSync as mkdirSync8 } from "fs";
|
|
11192
13138
|
import { homedir as homedir6 } from "os";
|
|
11193
13139
|
import { join as join9, resolve as resolve3, sep as sep3, parse as parsePath } from "path";
|
|
11194
|
-
var
|
|
13140
|
+
var log30 = forComponent("checkpoints");
|
|
11195
13141
|
var exec = promisify(execFile3);
|
|
11196
13142
|
var DEFAULT_EXCLUDE = [".agent/", ".git/", "node_modules/", "dist/", "build/", ".next/", "target/", ".venv/", "__pycache__/", "*.log"];
|
|
11197
13143
|
function isBroadRoot(p) {
|
|
@@ -11237,7 +13183,7 @@ var ShadowRepo = class {
|
|
|
11237
13183
|
writeFileSync5(join9(this.gitDir, "info", "exclude"), this.exclude.join("\n") + "\n");
|
|
11238
13184
|
this.ready = true;
|
|
11239
13185
|
} catch (e) {
|
|
11240
|
-
|
|
13186
|
+
log30.debug(`git checkpoints unavailable for ${this.workTree}`, e);
|
|
11241
13187
|
this.ready = false;
|
|
11242
13188
|
}
|
|
11243
13189
|
return this.ready;
|
|
@@ -11248,7 +13194,7 @@ var ShadowRepo = class {
|
|
|
11248
13194
|
}
|
|
11249
13195
|
async commit(label, forced = []) {
|
|
11250
13196
|
await this.run("add", "-A");
|
|
11251
|
-
for (const p of forced) await this.run("add", "-f", "--", p).catch((e) =>
|
|
13197
|
+
for (const p of forced) await this.run("add", "-f", "--", p).catch((e) => log30.debug(`force-add failed: ${p}`, e));
|
|
11252
13198
|
await this.run("commit", "--allow-empty", "-q", "-m", label);
|
|
11253
13199
|
}
|
|
11254
13200
|
/** Inject the CURRENT (pre-edit) content of `paths` into the turn-open restore point by amending it.
|
|
@@ -11256,8 +13202,8 @@ var ShadowRepo = class {
|
|
|
11256
13202
|
* turn-boundary `add -A` would never have captured it. Amend (vs a new commit) keeps one restore
|
|
11257
13203
|
* point per turn, so the REPL's turn↔frame mapping stays intact. */
|
|
11258
13204
|
async amendForced(paths) {
|
|
11259
|
-
for (const p of paths) await this.run("add", "-f", "--", p).catch((e) =>
|
|
11260
|
-
await this.run("commit", "--amend", "--no-edit", "-q", "--allow-empty").catch((e) =>
|
|
13205
|
+
for (const p of paths) await this.run("add", "-f", "--", p).catch((e) => log30.debug(`force-capture failed: ${p}`, e));
|
|
13206
|
+
await this.run("commit", "--amend", "--no-edit", "-q", "--allow-empty").catch((e) => log30.debug("amend failed", e));
|
|
11261
13207
|
}
|
|
11262
13208
|
/** Commits on `ref`, oldest-first (canonical index space). */
|
|
11263
13209
|
async log(ref) {
|
|
@@ -11317,7 +13263,7 @@ var ShadowRepo = class {
|
|
|
11317
13263
|
await this.run("gc", "--auto", "-q").catch(() => {
|
|
11318
13264
|
});
|
|
11319
13265
|
} catch (e) {
|
|
11320
|
-
|
|
13266
|
+
log30.debug("checkpoint prune failed", e);
|
|
11321
13267
|
}
|
|
11322
13268
|
}
|
|
11323
13269
|
};
|
|
@@ -11385,7 +13331,7 @@ var GitCheckpoints = class {
|
|
|
11385
13331
|
use(sessionId) {
|
|
11386
13332
|
if (sessionId === this.session) return;
|
|
11387
13333
|
this.session = sessionId;
|
|
11388
|
-
if (this.started) for (const r of this.repos) void r.point(this.ref()).catch((e) =>
|
|
13334
|
+
if (this.started) for (const r of this.repos) void r.point(this.ref()).catch((e) => log30.debug("re-point failed", e));
|
|
11389
13335
|
}
|
|
11390
13336
|
async begin(label) {
|
|
11391
13337
|
if (!await this.start()) return;
|
|
@@ -11405,7 +13351,7 @@ var GitCheckpoints = class {
|
|
|
11405
13351
|
process.stderr.write(`\x1B[2m checkpoints: '${this.repos[i].root}' too large to snapshot in ${Math.round(this.options.opTimeoutMs / 1e3)}s \u2014 file rewind disabled here\x1B[0m
|
|
11406
13352
|
`);
|
|
11407
13353
|
} else {
|
|
11408
|
-
|
|
13354
|
+
log30.debug("checkpoint commit failed", e);
|
|
11409
13355
|
survivors.push(this.repos[i]);
|
|
11410
13356
|
survivorCaches.push(this.caches[i] ?? []);
|
|
11411
13357
|
}
|
|
@@ -11440,7 +13386,7 @@ var GitCheckpoints = class {
|
|
|
11440
13386
|
if (this.forced.has(abs)) continue;
|
|
11441
13387
|
this.forced.add(abs);
|
|
11442
13388
|
const i = this.repoIndexFor(abs);
|
|
11443
|
-
if (i >= 0) await this.repos[i].amendForced([abs]).catch((e) =>
|
|
13389
|
+
if (i >= 0) await this.repos[i].amendForced([abs]).catch((e) => log30.debug("amendForced failed", e));
|
|
11444
13390
|
}
|
|
11445
13391
|
}
|
|
11446
13392
|
};
|
|
@@ -12519,8 +14465,8 @@ var REWIND = "\0REWIND";
|
|
|
12519
14465
|
var PASTE_PREVIEW_MAX = 40;
|
|
12520
14466
|
function pastePreview(text) {
|
|
12521
14467
|
const line = text.split("\n").find((l) => l.trim()) ?? "";
|
|
12522
|
-
const
|
|
12523
|
-
return
|
|
14468
|
+
const clean3 = line.replace(/[[\]]/g, "").replace(/\s+/g, " ").trim();
|
|
14469
|
+
return clean3.length > PASTE_PREVIEW_MAX ? clean3.slice(0, PASTE_PREVIEW_MAX) + "\u2026" : clean3;
|
|
12524
14470
|
}
|
|
12525
14471
|
function applyVimNormal(s, key, str) {
|
|
12526
14472
|
const k = key?.name;
|
|
@@ -13293,7 +15239,7 @@ import { spawnSync as spawnSync5 } from "child_process";
|
|
|
13293
15239
|
import { writeFileSync as writeFileSync8, mkdirSync as mkdirSync10, readdirSync as readdirSync2, unlinkSync as unlinkSync3, chmodSync, existsSync as existsSync7 } from "fs";
|
|
13294
15240
|
import { homedir as homedir8 } from "os";
|
|
13295
15241
|
import { join as join12 } from "path";
|
|
13296
|
-
var
|
|
15242
|
+
var log31 = forComponent("os-sched");
|
|
13297
15243
|
var OsScheduler = class {
|
|
13298
15244
|
options;
|
|
13299
15245
|
constructor(options) {
|
|
@@ -13355,7 +15301,7 @@ var OsScheduler = class {
|
|
|
13355
15301
|
}
|
|
13356
15302
|
}
|
|
13357
15303
|
} catch (e) {
|
|
13358
|
-
|
|
15304
|
+
log31.debug(`cancel ${id}`, e);
|
|
13359
15305
|
}
|
|
13360
15306
|
for (const f of [`${id}.json`, `${id}.sh`]) {
|
|
13361
15307
|
try {
|
|
@@ -13472,7 +15418,7 @@ import { spawn as spawn3 } from "child_process";
|
|
|
13472
15418
|
import { existsSync as existsSync8, mkdirSync as mkdirSync11, unlinkSync as unlinkSync4, readdirSync as readdirSync3 } from "fs";
|
|
13473
15419
|
import { homedir as homedir9 } from "os";
|
|
13474
15420
|
import { join as join13 } from "path";
|
|
13475
|
-
var
|
|
15421
|
+
var log32 = forComponent("remote-trigger");
|
|
13476
15422
|
var TRIGGER_DIR = () => join13(homedir9(), ".agent", "triggers");
|
|
13477
15423
|
var sockPath = (sessionId, dir = TRIGGER_DIR()) => join13(dir, `${sessionId}.sock`);
|
|
13478
15424
|
var TriggerServer = class {
|
|
@@ -13510,13 +15456,13 @@ var TriggerServer = class {
|
|
|
13510
15456
|
conn.end(JSON.stringify({ ok: false, error: String(e) }) + "\n");
|
|
13511
15457
|
}
|
|
13512
15458
|
});
|
|
13513
|
-
conn.on("error", (e) =>
|
|
15459
|
+
conn.on("error", (e) => log32.debug("trigger conn error", e));
|
|
13514
15460
|
});
|
|
13515
|
-
this.server.on("error", (e) =>
|
|
15461
|
+
this.server.on("error", (e) => log32.debug("trigger server error", e));
|
|
13516
15462
|
this.server.listen(p);
|
|
13517
15463
|
this.path = p;
|
|
13518
15464
|
} catch (e) {
|
|
13519
|
-
|
|
15465
|
+
log32.debug("trigger server unavailable", e);
|
|
13520
15466
|
}
|
|
13521
15467
|
}
|
|
13522
15468
|
/** Re-bind on /resume (the session id changed). */
|
|
@@ -13638,7 +15584,7 @@ var strike = C("9");
|
|
|
13638
15584
|
var link = (text, url) => useColor ? `\x1B]8;;${url}\x1B\\${cyan(text)}\x1B]8;;\x1B\\` : `${text} (${url})`;
|
|
13639
15585
|
var mdPalette = { bold, dim, cyan, italic, strike, green, link };
|
|
13640
15586
|
var err = (s) => process.stderr.write(s);
|
|
13641
|
-
var
|
|
15587
|
+
var log33 = forComponent("cli");
|
|
13642
15588
|
var VERSION = (() => {
|
|
13643
15589
|
try {
|
|
13644
15590
|
return JSON.parse(readFileSync9(new URL("../package.json", import.meta.url), "utf8")).version ?? "?";
|
|
@@ -13745,6 +15691,10 @@ function parseArgs(argv) {
|
|
|
13745
15691
|
a.listModels = true;
|
|
13746
15692
|
if (nxt === "list" || nxt === "ls" || nxt === "?") i++;
|
|
13747
15693
|
} else a.model = argv[++i];
|
|
15694
|
+
if (a.model === "auto") {
|
|
15695
|
+
a.autoRoute = true;
|
|
15696
|
+
a.model = autoModels().standard;
|
|
15697
|
+
}
|
|
13748
15698
|
} else if (x === "-C" || x === "--cwd") a.cwd = val(++i, x);
|
|
13749
15699
|
else if (x === "-p" || x === "--print") {
|
|
13750
15700
|
const nxt = argv[i + 1];
|
|
@@ -13823,7 +15773,7 @@ var HELP = `agentx \u2014 agent runtime CLI
|
|
|
13823
15773
|
agentx -c "<task>" continue the most recent session
|
|
13824
15774
|
|
|
13825
15775
|
Flags:
|
|
13826
|
-
-m, --model <id> model (default anthropic/claude-sonnet-4-6); bare -m or -m list = list all models
|
|
15776
|
+
-m, --model <id> model (default auto: per-turn haiku/sonnet + opus offload when Jev is configured, else anthropic/claude-sonnet-4-6); bare -m or -m list = list all models
|
|
13827
15777
|
-C, --cwd <dir> working dir / project root (default .)
|
|
13828
15778
|
-c, --continue resume the most recent session in this dir
|
|
13829
15779
|
-r, --resume [id] resume a session \u2014 with an id, or bare for an interactive picker
|
|
@@ -14391,10 +16341,25 @@ function costOf(pricing, promptTokens = 0, completionTokens = 0, cacheCreationTo
|
|
|
14391
16341
|
const fresh = Math.max(0, promptTokens - cacheCreationTokens - cacheReadTokens);
|
|
14392
16342
|
return fresh / 1e3 * pricing.inputCostPer1K + cacheCreationTokens / 1e3 * pricing.inputCostPer1K * mult.write + cacheReadTokens / 1e3 * pricing.inputCostPer1K * mult.read + completionTokens / 1e3 * pricing.outputCostPer1K;
|
|
14393
16343
|
}
|
|
16344
|
+
var autoModels = () => ({ ...new ModelRouterOptions().models, ...JSON.parse(process.env.AGENTX_AUTO_MODELS || "{}") });
|
|
16345
|
+
function autoRouteSend(args, agent, session, pof) {
|
|
16346
|
+
if (!args.autoRoute) return void 0;
|
|
16347
|
+
const router = new ModelRouter({ costOf: turnCost, models: autoModels(), providerOptionsFor: pof });
|
|
16348
|
+
return async (c) => {
|
|
16349
|
+
const s = session();
|
|
16350
|
+
router.state = s.meta.router ?? {};
|
|
16351
|
+
try {
|
|
16352
|
+
return await router.send(agent, c);
|
|
16353
|
+
} finally {
|
|
16354
|
+
s.meta.router = router.state;
|
|
16355
|
+
}
|
|
16356
|
+
};
|
|
16357
|
+
}
|
|
14394
16358
|
function turnCost(model, usage) {
|
|
14395
|
-
|
|
16359
|
+
const priced = model.startsWith("claude-code/") ? `anthropic/${resolveModelAlias(model.slice("claude-code/".length))}` : model;
|
|
16360
|
+
return costOf(getModelInfo(priced)?.pricing, usage?.promptTokens ?? 0, usage?.completionTokens ?? 0, usage?.cacheCreationTokens ?? 0, usage?.cacheReadTokens ?? 0, priced);
|
|
14396
16361
|
}
|
|
14397
|
-
async function evaluateGoal(ai, condition, transcript,
|
|
16362
|
+
async function evaluateGoal(ai, condition, transcript, log34) {
|
|
14398
16363
|
const recent = transcript.filter((m) => m.role === "assistant").slice(-8).map((m) => {
|
|
14399
16364
|
const text = typeof m.content === "string" ? m.content : m.content.filter((p) => p.type === "text").map((p) => p.text).join(" ");
|
|
14400
16365
|
return text.slice(0, 600);
|
|
@@ -14414,7 +16379,7 @@ ${recent}` }
|
|
|
14414
16379
|
const match = r.content.match(/\{[\s\S]*\}/);
|
|
14415
16380
|
if (match) return JSON.parse(match[0]);
|
|
14416
16381
|
} catch (e) {
|
|
14417
|
-
|
|
16382
|
+
log34(dim(` (goal evaluator error: ${e?.message ?? e})
|
|
14418
16383
|
`));
|
|
14419
16384
|
}
|
|
14420
16385
|
return { met: false, reason: "evaluation unclear" };
|
|
@@ -14635,7 +16600,7 @@ function mcpAgentTools(mounted, opts) {
|
|
|
14635
16600
|
return tools;
|
|
14636
16601
|
}
|
|
14637
16602
|
async function closeMcp(mounted) {
|
|
14638
|
-
await Promise.all(mounted.map((m) => m.client.close().catch((e) =>
|
|
16603
|
+
await Promise.all(mounted.map((m) => m.client.close().catch((e) => log33.debug("mcp close failed", e))));
|
|
14639
16604
|
}
|
|
14640
16605
|
var IMG_EXT = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp" };
|
|
14641
16606
|
function mentionRefs(line) {
|
|
@@ -14650,7 +16615,7 @@ function readImageParts(cwd, line) {
|
|
|
14650
16615
|
if (!mime) continue;
|
|
14651
16616
|
const abs = ref.startsWith("~/") ? untilde(ref) : resolve4(cwd, ref);
|
|
14652
16617
|
try {
|
|
14653
|
-
|
|
16618
|
+
statSync6(abs);
|
|
14654
16619
|
} catch {
|
|
14655
16620
|
continue;
|
|
14656
16621
|
}
|
|
@@ -14666,7 +16631,7 @@ function pastePathClassifier(cwd) {
|
|
|
14666
16631
|
if (!/^(\/|~\/|\.\/|\.\.\/)/.test(t)) return null;
|
|
14667
16632
|
const abs = t.startsWith("~/") ? untilde(t) : resolve4(cwd, t);
|
|
14668
16633
|
try {
|
|
14669
|
-
if (!
|
|
16634
|
+
if (!statSync6(abs).isFile()) return null;
|
|
14670
16635
|
} catch {
|
|
14671
16636
|
return null;
|
|
14672
16637
|
}
|
|
@@ -14687,7 +16652,7 @@ async function expandMentions(fs, line) {
|
|
|
14687
16652
|
if (loaded.includes(ref) || missing.includes(ref)) continue;
|
|
14688
16653
|
if (ref.includes(":") && mcpMentionResolver) {
|
|
14689
16654
|
const body = await mcpMentionResolver(ref).catch((e) => {
|
|
14690
|
-
|
|
16655
|
+
log33.debug("mcp mention resolve failed", e);
|
|
14691
16656
|
return null;
|
|
14692
16657
|
});
|
|
14693
16658
|
if (body != null) {
|
|
@@ -14722,6 +16687,8 @@ function jsonResult(res, session) {
|
|
|
14722
16687
|
steps: res.steps,
|
|
14723
16688
|
tools: res.messages.slice(lastUser).filter((m) => m.role === "tool").length,
|
|
14724
16689
|
usage: res.usage,
|
|
16690
|
+
...res.routing ? { costUsd: res.costUsd, routing: res.routing } : {},
|
|
16691
|
+
// cast: tsup dts hoists a modelRouter import above the shebang
|
|
14725
16692
|
sessionId: session.meta.id,
|
|
14726
16693
|
// The parked question, machine-readable — the caller answers it and resumes `sessionId`.
|
|
14727
16694
|
...res.finishReason === "needs_input" && res.question ? { question: res.question } : {},
|
|
@@ -14774,7 +16741,7 @@ async function runTurn(agent, store, session, task, cp, cwd = process.cwd(), sen
|
|
|
14774
16741
|
try {
|
|
14775
16742
|
store.save(session);
|
|
14776
16743
|
} catch (ex) {
|
|
14777
|
-
|
|
16744
|
+
log33.debug("mid-turn session flush failed", ex);
|
|
14778
16745
|
}
|
|
14779
16746
|
}
|
|
14780
16747
|
return origNotify(e);
|
|
@@ -14795,14 +16762,14 @@ async function runTurn(agent, store, session, task, cp, cwd = process.cwd(), sen
|
|
|
14795
16762
|
} finally {
|
|
14796
16763
|
spinner.stop();
|
|
14797
16764
|
spinner.turnStart = 0;
|
|
14798
|
-
activeTurn = null;
|
|
14799
|
-
agent.options.signal = void 0;
|
|
16765
|
+
if (activeTurn === ctrl) activeTurn = null;
|
|
16766
|
+
if (agent.options.signal === ctrl.signal) agent.options.signal = void 0;
|
|
14800
16767
|
if (host && origNotify) host.notify = origNotify;
|
|
14801
16768
|
}
|
|
14802
16769
|
agent.options.host?.flushText?.();
|
|
14803
16770
|
flushLookupGroup();
|
|
14804
16771
|
const secs2 = ((Date.now() - t0) / 1e3).toFixed(1);
|
|
14805
|
-
const cost = turnCost(agent.options.model, res.usage);
|
|
16772
|
+
const cost = res.costUsd ?? turnCost(agent.options.model, res.usage);
|
|
14806
16773
|
const m = res.usageEstimated ? "~" : "";
|
|
14807
16774
|
const tok = res.usage?.totalTokens ? `${m}${(res.usage.totalTokens / 1e3).toFixed(1)}k tok \xB7 ${cost > 0 ? m + fmtUsd(cost) + " \xB7 " : ""}` : "";
|
|
14808
16775
|
agent.options.host?.takeToolCount?.();
|
|
@@ -14923,14 +16890,14 @@ var isCancelTeardown = (e) => {
|
|
|
14923
16890
|
function installCancelGuards(mounted) {
|
|
14924
16891
|
process.on("unhandledRejection", (e) => {
|
|
14925
16892
|
if (isCancelTeardown(e)) {
|
|
14926
|
-
|
|
16893
|
+
log33.debug("suppressed unhandledRejection (cursor stream cancel)", e);
|
|
14927
16894
|
return;
|
|
14928
16895
|
}
|
|
14929
|
-
|
|
16896
|
+
log33.error("unhandledRejection", e);
|
|
14930
16897
|
});
|
|
14931
16898
|
process.on("uncaughtException", (e) => {
|
|
14932
16899
|
if (isCancelTeardown(e)) {
|
|
14933
|
-
|
|
16900
|
+
log33.debug("suppressed uncaughtException (cursor stream cancel)", e);
|
|
14934
16901
|
return;
|
|
14935
16902
|
}
|
|
14936
16903
|
console.error(e);
|
|
@@ -14990,6 +16957,18 @@ async function repl(args, ai, cfg, cwd) {
|
|
|
14990
16957
|
let dx;
|
|
14991
16958
|
let voiceIO;
|
|
14992
16959
|
let voiceDiag;
|
|
16960
|
+
const decider = duplex && process.env.TYPESAFE_AI_KEY ? new DecideClient({ apiKey: process.env.TYPESAFE_AI_KEY }) : void 0;
|
|
16961
|
+
const instinct = decider && process.env.VOICE_INSTINCT === "1" ? new Instinct({
|
|
16962
|
+
decider,
|
|
16963
|
+
context: () => ({ agentSaid: dx?.recentExcerpt(1).replace(/^\w+: /, ""), recent: dx?.recentExcerpt(4) }),
|
|
16964
|
+
onDecision: ({ kind, ...d }) => {
|
|
16965
|
+
voiceDiag?.push({ t: performance.now(), kind: `instinct_${kind}`, ...d });
|
|
16966
|
+
const probs = Object.entries(d.probabilities).sort((a, b) => b[1] - a[1]).map(([k, p]) => `${k} ${Math.round(p * 100)}%`).join(" \xB7 ");
|
|
16967
|
+
const act = d.acted && !/stale|skipped/.test(d.acted) ? yellow(`\u26A1 ${d.acted}`) : dim(d.acted ?? "no-op");
|
|
16968
|
+
err("\r\x1B[0J" + dim(` \u{1F9E0} jev ${kind.padEnd(12)} ${probs} ${d.ms}ms "${d.heard.slice(-40)}" `) + act + "\n");
|
|
16969
|
+
editorRef?.redrawNow();
|
|
16970
|
+
}
|
|
16971
|
+
}) : void 0;
|
|
14993
16972
|
let voiceLineOpen = false;
|
|
14994
16973
|
const emotionsOn = process.env.VOICE_EMOTIONS !== "0";
|
|
14995
16974
|
let showEmotions = process.env.VOICE_SHOW_EMOTIONS === "1";
|
|
@@ -15180,6 +17159,8 @@ async function repl(args, ai, cfg, cwd) {
|
|
|
15180
17159
|
};
|
|
15181
17160
|
dx = new DuplexAgent({
|
|
15182
17161
|
ai,
|
|
17162
|
+
...instinct ? { bargeIntent: (t) => instinct.bargeIntent(t) } : {},
|
|
17163
|
+
decider,
|
|
15183
17164
|
fs: agent.options.fs,
|
|
15184
17165
|
memoryDir: agent.options.memoryDir,
|
|
15185
17166
|
memoryUserDir: agent.options.memoryUserDir,
|
|
@@ -15189,7 +17170,7 @@ async function repl(args, ai, cfg, cwd) {
|
|
|
15189
17170
|
providerOptionsFor: (m) => providerOptionsFor(m, cwd, cfg.mcpServers, mounted.map((x) => x.name)),
|
|
15190
17171
|
...(args.thinkModel ?? cfg.thinkModel) !== void 0 ? { thinkModel: (args.thinkModel ?? cfg.thinkModel) === false ? false : resolveModelOrNewest(String(args.thinkModel ?? cfg.thinkModel)) } : {},
|
|
15191
17172
|
host,
|
|
15192
|
-
...args.voice ? { voiceStyle: "conversational", progressUpdates: true, askRelay: true, emotionTags: emotionsOn } : {},
|
|
17173
|
+
...args.voice ? { voiceStyle: "conversational", progressUpdates: true, askRelay: true, emotionTags: emotionsOn, spokenBudgetChars: 350 } : {},
|
|
15193
17174
|
// voice: progress asides + worker questions relayed through the conversation; emotion tags taught only with a TTS that speaks them
|
|
15194
17175
|
// Per-TASK checkpoint frames (the natural undo unit in duplex = one delegation): opened BEFORE
|
|
15195
17176
|
// the worker spawns (post-spawn would race its first edits). `checkpoints` is bound below.
|
|
@@ -15219,7 +17200,9 @@ async function repl(args, ai, cfg, cwd) {
|
|
|
15219
17200
|
}
|
|
15220
17201
|
}
|
|
15221
17202
|
return parts.length ? parts.join("\n").slice(0, 2e3) : "(no memory yet)";
|
|
15222
|
-
}
|
|
17203
|
+
},
|
|
17204
|
+
// Cross-session recall: earlier conversations in this project (path = a session id / "previous").
|
|
17205
|
+
sessions: (which) => sessionRecap(store, session.meta.id, which)
|
|
15223
17206
|
},
|
|
15224
17207
|
// The voice runs on the REAL fs (it has no fs tools — harmless) so @mentions, !cmd and #note
|
|
15225
17208
|
// resolve against the project; + CC-parity chrome for its own tool calls (⚙ Act …).
|
|
@@ -15230,7 +17213,8 @@ async function repl(args, ai, cfg, cwd) {
|
|
|
15230
17213
|
}
|
|
15231
17214
|
const face = dx ? dx.voice : agent;
|
|
15232
17215
|
const work = workerOptions ?? agent.options;
|
|
15233
|
-
const sendVia = dx ? (c) => dx.send(c) : void 0;
|
|
17216
|
+
const sendVia = dx ? (c, o) => dx.send(c, o) : void 0;
|
|
17217
|
+
const routeVia = autoRouteSend(args, agent, () => session, (m) => providerOptionsFor(m, cwd, cfg.mcpServers, mounted.map((x) => x.name)));
|
|
15234
17218
|
const baseRules = () => [
|
|
15235
17219
|
...parsePermRules({ deny: args.disallowedTools }),
|
|
15236
17220
|
...parsePermRules({ allow: args.allowedTools }),
|
|
@@ -15335,7 +17319,7 @@ async function repl(args, ai, cfg, cwd) {
|
|
|
15335
17319
|
try {
|
|
15336
17320
|
mkdirSync12(dir, { recursive: true });
|
|
15337
17321
|
} catch (e) {
|
|
15338
|
-
|
|
17322
|
+
log33.debug("paste image dir create failed", e);
|
|
15339
17323
|
}
|
|
15340
17324
|
const img = grabClipboardImage(dir, String(Date.now()));
|
|
15341
17325
|
return img ? { display: "Image", ref: "@" + img.path, path: img.path } : null;
|
|
@@ -15448,7 +17432,7 @@ Added entries are loadable now via the Skill/SlashCommand tools; removed ones ar
|
|
|
15448
17432
|
mkdirSync12(join14(cwd, ".agent"), { recursive: true });
|
|
15449
17433
|
appendFileSync(histPath, line + "\n");
|
|
15450
17434
|
} catch (e) {
|
|
15451
|
-
|
|
17435
|
+
log33.debug("history write failed", e);
|
|
15452
17436
|
}
|
|
15453
17437
|
};
|
|
15454
17438
|
const ago = (t) => {
|
|
@@ -15520,7 +17504,7 @@ Added entries are loadable now via the Skill/SlashCommand tools; removed ones ar
|
|
|
15520
17504
|
try {
|
|
15521
17505
|
store.save(session);
|
|
15522
17506
|
} catch (e) {
|
|
15523
|
-
|
|
17507
|
+
log33.debug("session save after rewind failed", e);
|
|
15524
17508
|
}
|
|
15525
17509
|
err(green(" \u27F2 jumped back") + dim(` \u2014 ${face.transcript.length} message(s) kept; edit + resend
|
|
15526
17510
|
`));
|
|
@@ -15546,7 +17530,8 @@ Added entries are loadable now via the Skill/SlashCommand tools; removed ones ar
|
|
|
15546
17530
|
else err(red(" no such session\n"));
|
|
15547
17531
|
};
|
|
15548
17532
|
const announcedTasks = /* @__PURE__ */ new Set();
|
|
15549
|
-
const
|
|
17533
|
+
const replacedVoiceTurns = /* @__PURE__ */ new Set();
|
|
17534
|
+
const turn = async (task, opts) => {
|
|
15550
17535
|
if (bangContext.length) {
|
|
15551
17536
|
task = `${bangContext.join("\n\n")}
|
|
15552
17537
|
|
|
@@ -15554,20 +17539,20 @@ ${task}`;
|
|
|
15554
17539
|
bangContext.length = 0;
|
|
15555
17540
|
}
|
|
15556
17541
|
const delta = await refreshCatalogs().catch((e) => {
|
|
15557
|
-
|
|
17542
|
+
log33.debug("catalog refresh failed", e);
|
|
15558
17543
|
return "";
|
|
15559
17544
|
});
|
|
15560
17545
|
if (delta) {
|
|
15561
17546
|
err(dim(" \u27F3 skill/command catalog changed \u2014 delta attached to this turn\n"));
|
|
15562
17547
|
task += "\n\n" + delta;
|
|
15563
17548
|
}
|
|
15564
|
-
const r = await runTurn(face, store, session, task, duplex ? void 0 : checkpoints, cwd, sendVia);
|
|
17549
|
+
const r = await runTurn(face, store, session, task, duplex ? void 0 : checkpoints, cwd, sendVia ? (c) => sendVia(c, opts) : routeVia);
|
|
15565
17550
|
if (voiceIO) {
|
|
15566
17551
|
voiceReasonEnd();
|
|
15567
17552
|
voiceEchoEnd();
|
|
15568
17553
|
editorRef?.resume();
|
|
15569
17554
|
}
|
|
15570
|
-
voiceIO?.endSpeech();
|
|
17555
|
+
if (!(opts?.id && replacedVoiceTurns.delete(opts.id))) voiceIO?.endSpeech();
|
|
15571
17556
|
if (dx) {
|
|
15572
17557
|
const fresh = [...dx.tasks.values()].filter((t) => t.status === "running" && !announcedTasks.has(t.id));
|
|
15573
17558
|
fresh.forEach((t) => announcedTasks.add(t.id));
|
|
@@ -15818,7 +17803,7 @@ ${task}`;
|
|
|
15818
17803
|
desc: "rescan skills/commands dirs and rebuild the system prompt (one cache miss) \u2014 picks up entries created mid-session",
|
|
15819
17804
|
run: async () => {
|
|
15820
17805
|
await refreshCatalogs().catch((e) => {
|
|
15821
|
-
|
|
17806
|
+
log33.debug("catalog refresh failed", e);
|
|
15822
17807
|
});
|
|
15823
17808
|
face.reprepare();
|
|
15824
17809
|
err(green(` \u2713 reloaded \u2014 ${skills.length} skill(s), ${cmds.length} command(s); system prompt rebuilds on next message
|
|
@@ -16320,7 +18305,7 @@ ${task}`;
|
|
|
16320
18305
|
try {
|
|
16321
18306
|
for (const def of (await loadAgents(fs2, d)).agents) if (!seen.has(def.name)) seen.set(def.name, { def, from: d });
|
|
16322
18307
|
} catch (e) {
|
|
16323
|
-
|
|
18308
|
+
log33.debug(`loadAgents(${d}) failed`, e);
|
|
16324
18309
|
}
|
|
16325
18310
|
}
|
|
16326
18311
|
if (!seen.size) {
|
|
@@ -16408,7 +18393,7 @@ ${task}`;
|
|
|
16408
18393
|
}
|
|
16409
18394
|
if (idx >= 0) {
|
|
16410
18395
|
const old = mounted.splice(idx, 1)[0];
|
|
16411
|
-
await old.client.close().catch((e) =>
|
|
18396
|
+
await old.client.close().catch((e) => log33.debug("mcp close failed", e));
|
|
16412
18397
|
}
|
|
16413
18398
|
try {
|
|
16414
18399
|
const m = await mountMcpServer(name, conf);
|
|
@@ -16436,7 +18421,7 @@ ${task}`;
|
|
|
16436
18421
|
}
|
|
16437
18422
|
const m = mounted.splice(idx, 1)[0];
|
|
16438
18423
|
remountMcpTools();
|
|
16439
|
-
await m.client.close().catch((e) =>
|
|
18424
|
+
await m.client.close().catch((e) => log33.debug("mcp close failed", e));
|
|
16440
18425
|
err(dim(` removed "${name}"
|
|
16441
18426
|
`));
|
|
16442
18427
|
return;
|
|
@@ -16639,7 +18624,7 @@ ${task}`;
|
|
|
16639
18624
|
try {
|
|
16640
18625
|
return readdirSync4(join14(cwd, absDir.replace(/^\/+/, "")), { withFileTypes: true }).map((d) => ({ name: d.name, dir: d.isDirectory() }));
|
|
16641
18626
|
} catch (e) {
|
|
16642
|
-
|
|
18627
|
+
log33.debug("completion readdir failed", absDir, e);
|
|
16643
18628
|
return null;
|
|
16644
18629
|
}
|
|
16645
18630
|
};
|
|
@@ -16738,7 +18723,8 @@ ${task}`;
|
|
|
16738
18723
|
};
|
|
16739
18724
|
let prefill;
|
|
16740
18725
|
let tick = 0;
|
|
16741
|
-
const dispatchLine = async (line) => {
|
|
18726
|
+
const dispatchLine = async (line, opts) => {
|
|
18727
|
+
if (!opts?.id) takeCut(voiceIO, false);
|
|
16742
18728
|
if (/^[!#/]/.test(line)) dispatchPending = false;
|
|
16743
18729
|
history.unshift(line.replace(/\n+/g, " \u23CE "));
|
|
16744
18730
|
remember(line.replace(/\n+/g, " \u23CE "));
|
|
@@ -16775,7 +18761,7 @@ ${out}
|
|
|
16775
18761
|
return;
|
|
16776
18762
|
}
|
|
16777
18763
|
await refreshCatalogs().catch((e) => {
|
|
16778
|
-
|
|
18764
|
+
log33.debug("catalog refresh failed", e);
|
|
16779
18765
|
});
|
|
16780
18766
|
const sk = skills.find((x) => x.name === name);
|
|
16781
18767
|
if (sk) {
|
|
@@ -16795,7 +18781,7 @@ ${out}
|
|
|
16795
18781
|
}
|
|
16796
18782
|
const task = pendingImages.length ? `${line} ${pendingImages.map((p) => "@" + p).join(" ")}` : line;
|
|
16797
18783
|
pendingImages.length = 0;
|
|
16798
|
-
await turn(task);
|
|
18784
|
+
await turn(task, opts);
|
|
16799
18785
|
if (goalCondition && !aborting && !exitRequested) {
|
|
16800
18786
|
goalTurns++;
|
|
16801
18787
|
persistGoal();
|
|
@@ -16804,6 +18790,11 @@ ${out}
|
|
|
16804
18790
|
if (exitRequested) return "quit";
|
|
16805
18791
|
};
|
|
16806
18792
|
let voicePartial = "";
|
|
18793
|
+
let interjectNote = "";
|
|
18794
|
+
const ackedNote = (said) => said ? `
|
|
18795
|
+
[you already said "${said}" out loud to open this reply \u2014 do not acknowledge again; go straight on.]` : "";
|
|
18796
|
+
let voiceSeq = 0;
|
|
18797
|
+
let lastVoiceTurn;
|
|
16807
18798
|
let partialRedraw = null;
|
|
16808
18799
|
const startVoice = async (greet) => {
|
|
16809
18800
|
if (voiceIO) return true;
|
|
@@ -16861,7 +18852,7 @@ ${out}
|
|
|
16861
18852
|
voiceEchoEnd();
|
|
16862
18853
|
if (phase === "speaking") err("\r\x1B[0J" + yellow(" \u270B interrupted\n"));
|
|
16863
18854
|
},
|
|
16864
|
-
onUtterance: (text) => {
|
|
18855
|
+
onUtterance: (text, meta) => {
|
|
16865
18856
|
voicePartial = "";
|
|
16866
18857
|
if (!text.trim()) return;
|
|
16867
18858
|
if (matchVoiceCommand(text) === "off") {
|
|
@@ -16876,15 +18867,24 @@ ${out}
|
|
|
16876
18867
|
})();
|
|
16877
18868
|
return;
|
|
16878
18869
|
}
|
|
16879
|
-
const
|
|
16880
|
-
const
|
|
16881
|
-
|
|
16882
|
-
|
|
16883
|
-
|
|
18870
|
+
const replaces = replacedVoiceTurn(lastVoiceTurn, meta);
|
|
18871
|
+
const { reply: cut, delivery, heard } = takeCut(voiceIO, !replaces);
|
|
18872
|
+
if (delivery || cut) dx?.parkCutDelivery(delivery, !!cut, heard);
|
|
18873
|
+
const cutReply = cut && cut.full.length - cut.heard.length > 40 ? cut : null;
|
|
18874
|
+
const noteP = replaces ? replaces.note : !cutReply ? Promise.resolve("") : (instinct ? instinct.bargeIntent(text).catch((e) => {
|
|
18875
|
+
log33.warn(`barge intent failed \u2014 model decides: ${e.message}`);
|
|
18876
|
+
return void 0;
|
|
18877
|
+
}) : Promise.resolve(void 0)).then(async (intent) => interruptionNote(cutReply, intent) + ackedNote(await instinct?.ackFor(text)));
|
|
18878
|
+
const cutIn = replaces ? replaces.cutIn : interjectNote;
|
|
18879
|
+
interjectNote = "";
|
|
18880
|
+
const id = `v${++voiceSeq}`;
|
|
18881
|
+
lastVoiceTurn = { id, text, note: noteP, cutIn };
|
|
18882
|
+
if (replaces) replacedVoiceTurns.add(replaces.id);
|
|
18883
|
+
if (/^[!#/]/.test(text.trim()) || cutReply || cutIn || replaces || pendingImages.length) dx?.abortSpeculation();
|
|
16884
18884
|
if (!/^[!#/]/.test(text.trim())) voiceIO.beginSpeech(true);
|
|
16885
18885
|
err(`\r\x1B[K ${bold(cyan("\u{1F3A4} \u203A"))} ${text}
|
|
16886
18886
|
`);
|
|
16887
|
-
void dispatchLine(text + note).then(async (r) => {
|
|
18887
|
+
void noteP.then((note) => dispatchLine(text + note + cutIn, { id, replaces: replaces?.id })).then(async (r) => {
|
|
16888
18888
|
if (r === "quit") {
|
|
16889
18889
|
await voiceIO?.awaitIdle();
|
|
16890
18890
|
editorRef?.abort();
|
|
@@ -16892,6 +18892,43 @@ ${out}
|
|
|
16892
18892
|
}).finally(() => editorRef?.redrawNow());
|
|
16893
18893
|
}
|
|
16894
18894
|
});
|
|
18895
|
+
if (instinct) {
|
|
18896
|
+
const v = voiceIO, o = v.options;
|
|
18897
|
+
const { onPartial } = o;
|
|
18898
|
+
const onUtterance = o.onUtterance;
|
|
18899
|
+
const listening = () => voiceIO === v && v.state === "listening" && !v.audible;
|
|
18900
|
+
const cutIns = new CutInAbsorber();
|
|
18901
|
+
o.onPartial = (text) => {
|
|
18902
|
+
if (listening()) instinct.hear(text);
|
|
18903
|
+
onPartial(text);
|
|
18904
|
+
};
|
|
18905
|
+
bindOverlapInstinct(v, instinct, () => voiceIO === v);
|
|
18906
|
+
bindAckInstinct(v, instinct);
|
|
18907
|
+
o.onUtterance = (text, meta) => {
|
|
18908
|
+
instinct.reset();
|
|
18909
|
+
if (cutIns.absorbs(text, meta)) {
|
|
18910
|
+
voicePartial = "";
|
|
18911
|
+
voiceDiag?.push({ t: performance.now(), kind: "instinct_final_absorbed", text });
|
|
18912
|
+
return;
|
|
18913
|
+
}
|
|
18914
|
+
onUtterance(text, meta);
|
|
18915
|
+
};
|
|
18916
|
+
Object.assign(instinct.options, {
|
|
18917
|
+
// Engine blip path: throwaway TTS context, no floor claim. Private in bod-voice — upstream a public
|
|
18918
|
+
// `backchannel()` if this graduates from the spike. AEC only (the blip would deafen half-duplex).
|
|
18919
|
+
onBackchannel: () => {
|
|
18920
|
+
if (!listening() || !v.usingAec) return false;
|
|
18921
|
+
v.fireBackchannel();
|
|
18922
|
+
},
|
|
18923
|
+
onInterject: (heard) => {
|
|
18924
|
+
if (!listening()) return false;
|
|
18925
|
+
cutIns.interjected(heard);
|
|
18926
|
+
interjectNote = `
|
|
18927
|
+
[you are CUTTING IN \u2014 the user is still mid-sentence and has said only this so far. Do it the way a person would: one short natural interjection (a correction, the word they're reaching for, or the answer), then stop and let them continue.]`;
|
|
18928
|
+
onUtterance(heard);
|
|
18929
|
+
}
|
|
18930
|
+
});
|
|
18931
|
+
}
|
|
16895
18932
|
{
|
|
16896
18933
|
const vo = voiceIO.options;
|
|
16897
18934
|
voiceDiag.push({
|
|
@@ -16909,9 +18946,11 @@ ${out}
|
|
|
16909
18946
|
bargeIn: vo.bargeIn,
|
|
16910
18947
|
overlapPause: vo.overlapPause,
|
|
16911
18948
|
overlapResumeMs: vo.overlapResumeMs,
|
|
18949
|
+
overlapDecideMs: vo.overlapDecideMs,
|
|
16912
18950
|
speculativeMs: vo.speculativeMs,
|
|
16913
18951
|
backchannelMs: vo.backchannelMs,
|
|
16914
|
-
emotions: vo.emotions
|
|
18952
|
+
emotions: vo.emotions,
|
|
18953
|
+
ackPick: !!vo.pickAck
|
|
16915
18954
|
}
|
|
16916
18955
|
});
|
|
16917
18956
|
}
|
|
@@ -17222,6 +19261,10 @@ async function main() {
|
|
|
17222
19261
|
}
|
|
17223
19262
|
const cwd = resolve4(args.cwd ?? process.cwd());
|
|
17224
19263
|
const cfg = await loadConfig(cwd);
|
|
19264
|
+
if (!args.autoRoute && !args.model && (cfg.model === "auto" || !cfg.model && await jevAvailable())) {
|
|
19265
|
+
args.autoRoute = true;
|
|
19266
|
+
args.model = autoModels().standard;
|
|
19267
|
+
}
|
|
17225
19268
|
cfg.permissions = mergePerms(loadClaudeSettings(cwd), mergePerms(loadPersistedRules(cwd), cfg.permissions));
|
|
17226
19269
|
if (canPrompt && !args.yes && !isTrusted(cwd)) {
|
|
17227
19270
|
const ok = await makeHost().confirm?.(`Trust this directory? agent.libx will read and edit files in:
|
|
@@ -17271,7 +19314,7 @@ async function main() {
|
|
|
17271
19314
|
const store = new SessionStore(cwd);
|
|
17272
19315
|
const session = startSession(args, store, agent, cwd);
|
|
17273
19316
|
process.once("SIGINT", () => activeTurn?.abort());
|
|
17274
|
-
const { ok, res } = await runTurn(agent, store, session, args.task, void 0, cwd);
|
|
19317
|
+
const { ok, res } = await runTurn(agent, store, session, args.task, void 0, cwd, autoRouteSend(args, agent, () => session, (m) => providerOptionsFor(m, cwd, cfg.mcpServers, mounted.map((x) => x.name))));
|
|
17275
19318
|
if (cfg.reflectOnFailure && !ok && res?.finishReason !== "needs_input" && res && agent.options.memoryDir) {
|
|
17276
19319
|
const _fsBase = agent.options.fs.getCwd() === "/" ? "" : agent.options.fs.getCwd();
|
|
17277
19320
|
const slug2 = await reflectOnRun({ ai, model: agent.options.model, fs: agent.options.fs, dir: primaryMemDir(agent.options.memoryDir, `${_fsBase}/.agent/memory`), result: res });
|
|
@@ -17318,6 +19361,7 @@ export {
|
|
|
17318
19361
|
runShellLine,
|
|
17319
19362
|
runTurn,
|
|
17320
19363
|
setMcpMentionResolver,
|
|
17321
|
-
summarizeResult
|
|
19364
|
+
summarizeResult,
|
|
19365
|
+
turnCost
|
|
17322
19366
|
};
|
|
17323
19367
|
//# sourceMappingURL=cli.js.map
|