@livx.cc/agentx 0.99.104 → 0.99.106
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/README.md +2 -0
- package/dist/cli.d.ts +13 -6
- package/dist/cli.js +3548 -2672
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +208 -17
- package/dist/index.js +1737 -116
- package/dist/index.js.map +1 -1
- package/dist/{mcp-BhTxN2I7.d.ts → mcp-CUo-9LUe.d.ts} +1 -1
- package/dist/mcp.client.d.ts +2 -2
- package/dist/{Agent--Kxl9DXO.d.ts → secondChannel-BYW5qPAJ.d.ts} +291 -2
- package/dist/{tools-lC3jn60o.d.ts → tools-DZHZIyxh.d.ts} +4 -0
- package/dist/tools.shell.d.ts +1 -1
- package/dist/tools.shell.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -683,16 +683,16 @@ ${entries.map((s) => " " + s).join("\n")}`);
|
|
|
683
683
|
return blocks.length ? blocks.join("\n") : `(no ${label} found)`;
|
|
684
684
|
}
|
|
685
685
|
function fuzzyLineReplace(content, oldStr, newStr) {
|
|
686
|
-
const
|
|
686
|
+
const norm4 = (s) => s.trim();
|
|
687
687
|
const cl = content.split("\n");
|
|
688
|
-
const ol = oldStr.split("\n").map(
|
|
688
|
+
const ol = oldStr.split("\n").map(norm4);
|
|
689
689
|
while (ol.length && ol[ol.length - 1] === "") ol.pop();
|
|
690
690
|
while (ol.length && ol[0] === "") ol.shift();
|
|
691
691
|
if (!ol.length) return null;
|
|
692
692
|
const matches = [];
|
|
693
693
|
for (let i2 = 0; i2 + ol.length <= cl.length; i2++) {
|
|
694
694
|
let ok = true;
|
|
695
|
-
for (let j = 0; j < ol.length; j++) if (
|
|
695
|
+
for (let j = 0; j < ol.length; j++) if (norm4(cl[i2 + j]) !== ol[j]) {
|
|
696
696
|
ok = false;
|
|
697
697
|
break;
|
|
698
698
|
}
|
|
@@ -3149,7 +3149,7 @@ var init_tools_shell = __esm({
|
|
|
3149
3149
|
});
|
|
3150
3150
|
|
|
3151
3151
|
// src/ccBridge.ts
|
|
3152
|
-
var CC_BRIDGED = /* @__PURE__ */ new Set(["Recall", "Remember", "MemorySearch"]);
|
|
3152
|
+
var CC_BRIDGED = /* @__PURE__ */ new Set(["Recall", "Remember", "MemorySearch", "Forget"]);
|
|
3153
3153
|
function ccToolServer(name, tools, call) {
|
|
3154
3154
|
const instance = {
|
|
3155
3155
|
async connect(t) {
|
|
@@ -3508,8 +3508,9 @@ init_tools_structured();
|
|
|
3508
3508
|
var MAX_INDEX = 25;
|
|
3509
3509
|
var MAX_INDEX_LINES = 200;
|
|
3510
3510
|
var MAX_INDEX_BYTES = 25e3;
|
|
3511
|
-
var MEMORY_PROMPT = '## Memory guidelines\n**When to Remember:** user corrections ("no, do X instead"), preferences ("I prefer Y"), project constraints, recurring gotchas, role/context ("I\'m a data scientist"), workflow patterns confirmed by the user. Capture the WHY, not just the what.\n**When NOT to Remember:** task-specific scratch, transient state, things derivable from code/git, conversation filler, anything already in instruction files. If removing the memory wouldn\'t change future behavior, don\'t save it.\n**Types** (pass to Remember \u2014 this determines where the memory is stored):\n- `user` \u2014 role, preferences, knowledge level, collaboration style (GLOBAL \u2014 follows you across projects)\n- `feedback` \u2014 corrections, what to avoid/repeat, with WHY (GLOBAL)\n- `project` \u2014 ongoing work, goals, constraints, deadlines (LOCAL to this project; convert relative dates to absolute)\n- `reference` \u2014 pointers to external resources, URLs, vendor docs (LOCAL to this project)\n**Before acting on a recalled memory:** verify it\'s still true \u2014 a memory naming a file/function/flag is a claim from when it was written. Grep/read to confirm before recommending. Stale memory \u2192 update via Remember (same slug overwrites).\n**Dedup:** before writing, check if a similar memory exists (Recall or MemorySearch). Update the existing one rather than creating duplicates.\nCall `Recall` with a slug (or multiple slugs/a pattern) to load full bodies when relevant.\nCall `MemorySearch` with a query to find memories by content when you don\'t know the slug.\nCall `Remember` to persist a durable fact for future sessions.';
|
|
3512
|
-
var VOICE_MEMORY_PROMPT = `You have Remember and
|
|
3511
|
+
var MEMORY_PROMPT = '## Memory guidelines\n**When to Remember:** user corrections ("no, do X instead"), preferences ("I prefer Y"), project constraints, recurring gotchas, role/context ("I\'m a data scientist"), workflow patterns confirmed by the user. Capture the WHY, not just the what.\n**When NOT to Remember:** task-specific scratch, transient state, things derivable from code/git, conversation filler, anything already in instruction files. If removing the memory wouldn\'t change future behavior, don\'t save it.\n**Types** (pass to Remember \u2014 this determines where the memory is stored):\n- `user` \u2014 role, preferences, knowledge level, collaboration style (GLOBAL \u2014 follows you across projects)\n- `feedback` \u2014 corrections, what to avoid/repeat, with WHY (GLOBAL)\n- `project` \u2014 ongoing work, goals, constraints, deadlines (LOCAL to this project; convert relative dates to absolute)\n- `reference` \u2014 pointers to external resources, URLs, vendor docs (LOCAL to this project)\n**Before acting on a recalled memory:** verify it\'s still true \u2014 a memory naming a file/function/flag is a claim from when it was written. Grep/read to confirm before recommending. Stale memory \u2192 update via Remember (same slug overwrites).\n**Forget:** when the user asks to remove/delete/forget a memory, call `Forget` with its slug \u2014 it deletes the fact and its index line. Never "remove" by re-Remembering a note that says it was removed: that keeps the memory.\n**Dedup:** before writing, check if a similar memory exists (Recall or MemorySearch). Update the existing one rather than creating duplicates.\nCall `Recall` with a slug (or multiple slugs/a pattern) to load full bodies when relevant.\nCall `MemorySearch` with a query to find memories by content when you don\'t know the slug.\nCall `Remember` to persist a durable fact for future sessions.';
|
|
3512
|
+
var VOICE_MEMORY_PROMPT = `You have Remember, Recall and Forget tools \u2014 use them directly, no delegation needed.
|
|
3513
|
+
When the user asks to remove/delete/forget something you remember, call Forget with its slug (MemorySearch first if unsure) \u2014 never Remember a "removed" note over it; only say it is gone after Forget confirms.
|
|
3513
3514
|
IMPLICIT CAPTURE: when the user shares their name, role, a preference, a correction ("no, do X instead"), or a project constraint \u2014 call Remember immediately without announcing it. Natural memory, not a ceremony.
|
|
3514
3515
|
For explicit "remember X" requests, also call Remember directly and confirm briefly ("got it").
|
|
3515
3516
|
Do NOT remember: transient task details, conversation filler, things you'd forget in a real conversation.
|
|
@@ -3518,7 +3519,7 @@ For anything requiring files, shell, or web \u2014 still Act.`;
|
|
|
3518
3519
|
async function loadMemory(fs, dir, opts = {}) {
|
|
3519
3520
|
const dirs = (Array.isArray(dir) ? dir : [dir]).filter(Boolean);
|
|
3520
3521
|
const writeDir = dirs[0];
|
|
3521
|
-
const tools = [recallTool(fs, dirs), rememberTool(fs, writeDir, opts), memorySearchTool(fs, dirs)];
|
|
3522
|
+
const tools = [recallTool(fs, dirs), rememberTool(fs, writeDir, opts), memorySearchTool(fs, dirs), forgetTool(fs, [...dirs, ...opts.userDir ? [opts.userDir] : []])];
|
|
3522
3523
|
const allPointers = [];
|
|
3523
3524
|
const seenSlugs = /* @__PURE__ */ new Set();
|
|
3524
3525
|
let header = "";
|
|
@@ -3760,6 +3761,39 @@ function rememberTool(fs, dir, memOpts = {}) {
|
|
|
3760
3761
|
}
|
|
3761
3762
|
};
|
|
3762
3763
|
}
|
|
3764
|
+
async function forgetFact(fs, dirs, slug2) {
|
|
3765
|
+
const from = [];
|
|
3766
|
+
for (const d of [...new Set(dirs.filter(Boolean))]) {
|
|
3767
|
+
const path = `${d}/${slug2}.md`;
|
|
3768
|
+
const had = await fs.exists(path);
|
|
3769
|
+
if (had) await fs.deleteFile(path);
|
|
3770
|
+
const indexPath = `${d}/MEMORY.md`;
|
|
3771
|
+
let unindexed = false;
|
|
3772
|
+
if (await fs.exists(indexPath)) {
|
|
3773
|
+
const lines = (await fs.readFile(indexPath)).split("\n");
|
|
3774
|
+
const kept = lines.filter((l) => !l.includes(`(${slug2}.md)`));
|
|
3775
|
+
if (kept.length !== lines.length) {
|
|
3776
|
+
await fs.writeFile(indexPath, kept.join("\n"));
|
|
3777
|
+
unindexed = true;
|
|
3778
|
+
}
|
|
3779
|
+
}
|
|
3780
|
+
if (had || unindexed) from.push(d);
|
|
3781
|
+
}
|
|
3782
|
+
return from;
|
|
3783
|
+
}
|
|
3784
|
+
function forgetTool(fs, dirs) {
|
|
3785
|
+
return {
|
|
3786
|
+
name: "Forget",
|
|
3787
|
+
description: 'Delete a memory for good (its fact file and index line) when the user asks to remove, delete or forget it. Pass the slug (MemorySearch if unsure). Re-Remembering a "removed" note does NOT delete anything.',
|
|
3788
|
+
parameters: { type: "object", required: ["slug"], properties: { slug: { type: "string", description: "fact slug (filename without .md)" } } },
|
|
3789
|
+
async run({ slug: slug2 }, _ctx) {
|
|
3790
|
+
const s = cleanSlug(slug2);
|
|
3791
|
+
if (!s) return "Error: pass the slug to forget.";
|
|
3792
|
+
const from = await forgetFact(fs, dirs, s);
|
|
3793
|
+
return from.length ? `Forgot '${s}' \u2014 deleted from ${from.length} memory scope(s); it will not be recalled again.` : `Error: no memory '${s}' found (nothing deleted). Use MemorySearch to find the right slug.`;
|
|
3794
|
+
}
|
|
3795
|
+
};
|
|
3796
|
+
}
|
|
3763
3797
|
|
|
3764
3798
|
// src/instructions.ts
|
|
3765
3799
|
var DEFAULT_NAMES = ["AGENT.md", "AGENTS.md", "CLAUDE.md"];
|
|
@@ -5117,6 +5151,7 @@ var Agent = class _Agent {
|
|
|
5117
5151
|
const start = Date.now();
|
|
5118
5152
|
this.parkedMs = 0;
|
|
5119
5153
|
this.ctx.needsInput = void 0;
|
|
5154
|
+
this.ctx.endTurn = void 0;
|
|
5120
5155
|
let toolCallsTotal = 0;
|
|
5121
5156
|
let lastFp = "";
|
|
5122
5157
|
let repeats = 0;
|
|
@@ -5409,6 +5444,13 @@ var Agent = class _Agent {
|
|
|
5409
5444
|
].join("\n");
|
|
5410
5445
|
return { text, steps, finishReason: "needs_input", messages: this.transcript, usage, usageEstimated, question };
|
|
5411
5446
|
}
|
|
5447
|
+
if (this.ctx.endTurn) {
|
|
5448
|
+
this.ctx.endTurn = void 0;
|
|
5449
|
+
if (this.drainInjections()) continue;
|
|
5450
|
+
log8.verbose(`turn ended by a tool (steps=${steps})`);
|
|
5451
|
+
await this.jobs.drain(o.signal);
|
|
5452
|
+
return { text: "", steps, finishReason: "stop", messages: this.transcript, usage, usageEstimated };
|
|
5453
|
+
}
|
|
5412
5454
|
this.drainInjections();
|
|
5413
5455
|
}
|
|
5414
5456
|
}
|
|
@@ -5973,14 +6015,14 @@ function summarize(messages, focus) {
|
|
|
5973
6015
|
if (lastAssistant) lines.push(`last assistant: ${lastAssistant}`);
|
|
5974
6016
|
if (carried.length) lines.push("earlier recap:", ...carried.map((l) => ` ${l}`));
|
|
5975
6017
|
if (focus?.trim()) {
|
|
5976
|
-
const
|
|
6018
|
+
const words3 = focus.toLowerCase().split(/\s+/).filter((w) => w.length > 2);
|
|
5977
6019
|
const kept = [];
|
|
5978
6020
|
const ordered = [...messages.filter((m) => !isSyntheticSummary(m)), ...messages.filter(isSyntheticSummary)];
|
|
5979
6021
|
outer: for (const msg of ordered) {
|
|
5980
6022
|
if (msg.role === "tool") continue;
|
|
5981
6023
|
for (const line of contentText(msg.content).split("\n")) {
|
|
5982
6024
|
const l = line.toLowerCase();
|
|
5983
|
-
if (line.trim() &&
|
|
6025
|
+
if (line.trim() && words3.some((w) => l.includes(w))) {
|
|
5984
6026
|
kept.push(line.trim().slice(0, 200));
|
|
5985
6027
|
if (kept.length >= 12) break outer;
|
|
5986
6028
|
}
|
|
@@ -7113,13 +7155,13 @@ function isTranscriptComplete(text) {
|
|
|
7113
7155
|
const trimmed = text.trim();
|
|
7114
7156
|
if (!trimmed) return false;
|
|
7115
7157
|
if (/[.?!]$/.test(trimmed)) return true;
|
|
7116
|
-
const
|
|
7117
|
-
const lastWord =
|
|
7158
|
+
const words3 = trimmed.toLowerCase().split(/\s+/);
|
|
7159
|
+
const lastWord = words3[words3.length - 1];
|
|
7118
7160
|
if (COMPLETE_ENDINGS.has(lastWord)) return true;
|
|
7119
7161
|
if (INCOMPLETE_ENDINGS.has(lastWord)) return false;
|
|
7120
7162
|
const lower = trimmed.toLowerCase();
|
|
7121
7163
|
for (const marker of CONTINUATION_MARKERS) if (lower.endsWith(marker)) return false;
|
|
7122
|
-
if (
|
|
7164
|
+
if (words3.length <= 2) return false;
|
|
7123
7165
|
return true;
|
|
7124
7166
|
}
|
|
7125
7167
|
var MAX_HOLD_MS = 2e3;
|
|
@@ -7520,13 +7562,13 @@ var VoiceEngine = class _VoiceEngine {
|
|
|
7520
7562
|
};
|
|
7521
7563
|
if (this.options.revealMode === "word" || this.options.heardTimestamps) {
|
|
7522
7564
|
this.tts.wantTimestamps = true;
|
|
7523
|
-
this.tts.onTimestamps = (
|
|
7565
|
+
this.tts.onTimestamps = (words3, start) => {
|
|
7524
7566
|
if (this.interrupted) return;
|
|
7525
7567
|
start.forEach((s, i) => {
|
|
7526
|
-
if (this.wordAnchorPending) this.wordPending.push([
|
|
7568
|
+
if (this.wordAnchorPending) this.wordPending.push([words3[i] ?? "", s]);
|
|
7527
7569
|
else {
|
|
7528
7570
|
this.wordStarts.push(this.wordOrigin + s);
|
|
7529
|
-
this.wordTexts.push(
|
|
7571
|
+
this.wordTexts.push(words3[i] ?? "");
|
|
7530
7572
|
}
|
|
7531
7573
|
});
|
|
7532
7574
|
};
|
|
@@ -7668,22 +7710,22 @@ var VoiceEngine = class _VoiceEngine {
|
|
|
7668
7710
|
if (this.adaptiveAckFired && this.ackProbe !== null) {
|
|
7669
7711
|
this.ackProbe += text;
|
|
7670
7712
|
const probe = this.ackProbe;
|
|
7671
|
-
const
|
|
7672
|
-
const m =
|
|
7673
|
-
if (!m &&
|
|
7713
|
+
const bare2 = probe.replace(/\[emotion[^\]]*\]/gi, "").replace(/^\s+/, "");
|
|
7714
|
+
const m = bare2.match(/^([^.!?…\n]*[.!?…\n])([\s\S]*)$/);
|
|
7715
|
+
if (!m && bare2.length < 80) return "";
|
|
7674
7716
|
this.ackProbe = null;
|
|
7675
7717
|
this.adaptiveAckFired = false;
|
|
7676
|
-
const
|
|
7677
|
-
const
|
|
7678
|
-
const same = this.ackPickedFired ?
|
|
7679
|
-
if (_VoiceEngine.ACK_LEADING.test(
|
|
7718
|
+
const lead2 = m ? m[1] : bare2;
|
|
7719
|
+
const norm4 = lead2.replace(/[^a-z ]/gi, " ").replace(/\s+/g, " ").trim().toLowerCase();
|
|
7720
|
+
const same = this.ackPickedFired ? bare2.match(_VoiceEngine.leadingPhrase(this.lastAck)) : null;
|
|
7721
|
+
if (_VoiceEngine.ACK_LEADING.test(norm4)) {
|
|
7680
7722
|
const rest = m ? m[2] : "";
|
|
7681
|
-
this.diag("reflex_ack_suppressed", { lead:
|
|
7723
|
+
this.diag("reflex_ack_suppressed", { lead: lead2.trim() });
|
|
7682
7724
|
if (!rest.trim()) return "";
|
|
7683
7725
|
text = rest;
|
|
7684
7726
|
} else if (same) {
|
|
7685
7727
|
this.diag("reflex_ack_suppressed", { lead: same[0].trim() });
|
|
7686
|
-
text =
|
|
7728
|
+
text = bare2.slice(same[0].length);
|
|
7687
7729
|
if (!text.trim()) return "";
|
|
7688
7730
|
} else {
|
|
7689
7731
|
text = probe;
|
|
@@ -7762,8 +7804,8 @@ var VoiceEngine = class _VoiceEngine {
|
|
|
7762
7804
|
while (n < this.wordStarts.length && this.wordStarts[n] <= playedSec) n++;
|
|
7763
7805
|
if (n !== this.revealedN) {
|
|
7764
7806
|
this.revealedN = n;
|
|
7765
|
-
const
|
|
7766
|
-
this.options.onReveal(
|
|
7807
|
+
const words3 = this.revealText.trim().split(/\s+/).filter(Boolean);
|
|
7808
|
+
this.options.onReveal(words3.slice(0, n).join(" "));
|
|
7767
7809
|
}
|
|
7768
7810
|
this.revealPoll = this.clock.setTimeout(tick, 60);
|
|
7769
7811
|
};
|
|
@@ -7790,8 +7832,8 @@ var VoiceEngine = class _VoiceEngine {
|
|
|
7790
7832
|
this.adaptiveAckFired = false;
|
|
7791
7833
|
this.ackProbe = null;
|
|
7792
7834
|
if (heldProbe) {
|
|
7793
|
-
const
|
|
7794
|
-
if (_VoiceEngine.ACK_LEADING.test(
|
|
7835
|
+
const norm4 = heldProbe.replace(/\[emotion[^\]]*\]/gi, "").replace(/[^a-z ]/gi, " ").replace(/\s+/g, " ").trim().toLowerCase();
|
|
7836
|
+
if (_VoiceEngine.ACK_LEADING.test(norm4)) this.diag("reflex_ack_suppressed", { lead: heldProbe.trim() });
|
|
7795
7837
|
else this.speakDelta(heldProbe);
|
|
7796
7838
|
}
|
|
7797
7839
|
if (!this.speaking) return;
|
|
@@ -8329,14 +8371,14 @@ var VoiceEngine = class _VoiceEngine {
|
|
|
8329
8371
|
if (!prev) return next;
|
|
8330
8372
|
if (!next) return prev;
|
|
8331
8373
|
const pw = prev.split(/\s+/), nw = next.split(/\s+/);
|
|
8332
|
-
const
|
|
8374
|
+
const norm4 = _VoiceEngine.normWord;
|
|
8333
8375
|
if (pw.length >= 2 && pw.length <= nw.length) {
|
|
8334
8376
|
let pfx = true;
|
|
8335
|
-
for (let i = 0; i < pw.length - 1; i++) if (
|
|
8377
|
+
for (let i = 0; i < pw.length - 1; i++) if (norm4(pw[i]) !== norm4(nw[i])) {
|
|
8336
8378
|
pfx = false;
|
|
8337
8379
|
break;
|
|
8338
8380
|
}
|
|
8339
|
-
if (pfx &&
|
|
8381
|
+
if (pfx && norm4(nw[pw.length - 1]).startsWith(norm4(pw[pw.length - 1]))) return next;
|
|
8340
8382
|
}
|
|
8341
8383
|
if (_VoiceEngine.restates(prev, next)) {
|
|
8342
8384
|
this.diag("merge_restated", { dropped: prev.slice(0, 60) });
|
|
@@ -8345,7 +8387,7 @@ var VoiceEngine = class _VoiceEngine {
|
|
|
8345
8387
|
const max = Math.min(pw.length, nw.length);
|
|
8346
8388
|
for (let k = max; k > 0; k--) {
|
|
8347
8389
|
let match = true;
|
|
8348
|
-
for (let i = 0; i < k; i++) if (
|
|
8390
|
+
for (let i = 0; i < k; i++) if (norm4(pw[pw.length - k + i]) !== norm4(nw[i])) {
|
|
8349
8391
|
match = false;
|
|
8350
8392
|
break;
|
|
8351
8393
|
}
|
|
@@ -8397,11 +8439,11 @@ var VoiceEngine = class _VoiceEngine {
|
|
|
8397
8439
|
* race), never worse. Fillers ("mhm") deliberately don't count as replies — see speakFiller. */
|
|
8398
8440
|
refinalizes(next) {
|
|
8399
8441
|
const pw = this.lastDispatchWords, nw = next.trim().split(/\s+/);
|
|
8400
|
-
const
|
|
8442
|
+
const norm4 = _VoiceEngine.normWord;
|
|
8401
8443
|
if (pw.length < 3 || nw.length < pw.length) return false;
|
|
8402
8444
|
if (this.clock.now() - this.lastDispatchAt >= _VoiceEngine.DUP_FINAL_MS) return false;
|
|
8403
|
-
for (let i = 0; i < pw.length - 1; i++) if (
|
|
8404
|
-
const pLast =
|
|
8445
|
+
for (let i = 0; i < pw.length - 1; i++) if (norm4(pw[i]) !== norm4(nw[i])) return false;
|
|
8446
|
+
const pLast = norm4(pw[pw.length - 1]), nSame = norm4(nw[pw.length - 1]);
|
|
8405
8447
|
if (!nSame.startsWith(pLast)) return false;
|
|
8406
8448
|
return nw.length > pw.length || nSame.length > pLast.length;
|
|
8407
8449
|
}
|
|
@@ -9233,7 +9275,98 @@ import { AIClient, DecideClient, JevAdapter, LlmDecideAdapter } from "ai.libx.js
|
|
|
9233
9275
|
// src/voice/overlap.ts
|
|
9234
9276
|
init_logging();
|
|
9235
9277
|
var log20 = forComponent("Overlap");
|
|
9278
|
+
var norm2 = (s) => s.toLowerCase().replace(/[’']/g, "'").replace(/[^\p{L}\p{N}' -]+/gu, " ").replace(/\s+/g, " ").trim();
|
|
9279
|
+
var HARD = /\b(?:stop|wait|no|nope|nah|hold on|hang on|hold up|pause|cancel|quiet|enough|shut up|never ?mind|actually|but|sorry what|what|why|how|who|when|where|which|can you|could you|tell me|i mean|excuse me)\b/;
|
|
9236
9280
|
var ACK = new Set("yeah yea yes yep yup ya ok okay k right sure true totally exactly definitely absolutely indeed mm mmm hmm mhm mm-hm uh-huh uh huh aha ah oh ooh wow nice cool great awesome perfect good fine interesting gotcha got it i see makes sense fair agreed correct of course for sure lol ha haha hahaha heh hehe yeah yeah alright all right sweet neat oh nice oh wow oh cool oh okay oh yeah oh right that's right that's true thats right thats true love it i know me too same".split(" "));
|
|
9281
|
+
var LEAD = /* @__PURE__ */ new Set(["and", "so", "um", "uh", "er", "well", "oh"]);
|
|
9282
|
+
var LEADER = /* @__PURE__ */ new Set(["yeah", "yea", "yes", "yep", "yup", "ok", "okay", "right", "sure", "alright", "mm", "mhm", "hmm", "uh-huh", "totally", "exactly", "true"]);
|
|
9283
|
+
var PHRASES = ["got it", "i see", "makes sense", "of course", "for sure", "all right", "that's right", "that's true", "thats right", "thats true", "love it", "i know", "me too", "uh huh"];
|
|
9284
|
+
function overlapRule(text) {
|
|
9285
|
+
if (/\?/.test(text)) return "cut";
|
|
9286
|
+
let t = norm2(text);
|
|
9287
|
+
if (!t) return void 0;
|
|
9288
|
+
if (HARD.test(t)) return "cut";
|
|
9289
|
+
for (const p of PHRASES) t = t.split(p).join(" ");
|
|
9290
|
+
const words3 = t.split(/[\s-]+/).filter(Boolean);
|
|
9291
|
+
const ack = (w) => ACK.has(w) || /^(?:ha)+h?$|^(?:he)+h?$|^m+(?:hm+)?$|^h?m+$/.test(w);
|
|
9292
|
+
if (words3.every(ack)) return "keep";
|
|
9293
|
+
const lead2 = words3.findIndex((w) => !LEAD.has(w));
|
|
9294
|
+
if (lead2 >= 0 && LEADER.has(words3[lead2]) && words3.slice(lead2).filter((w) => !ack(w) && !LEAD.has(w)).length <= 1) return "keep";
|
|
9295
|
+
return void 0;
|
|
9296
|
+
}
|
|
9297
|
+
function fastOverlapModel(ai, model = "groq/qwen/qwen3.8-27b") {
|
|
9298
|
+
return async (heard, signal) => {
|
|
9299
|
+
const prompt = 'Live voice call. The assistant is mid-sentence and the user just said this over it: "' + heard + '"\nIs it ONLY a listener cue \u2014 agreement, acknowledgement, a short reaction or laugh \u2014 so the assistant should keep talking? Answer no if they want the floor: a question, objection, correction, request, or new information.\nAnswer yes or no.';
|
|
9300
|
+
const reasoning = /qwen/.test(model) ? { providerOptions: { reasoning_effort: "none" } } : /gpt-oss/.test(model) ? { providerOptions: { reasoning_effort: "low" } } : {};
|
|
9301
|
+
const r = await ai.chat({ model, messages: [{ role: "user", content: prompt }], stream: false, signal, temperature: 0, maxTokens: /gpt-oss/.test(model) ? 200 : 3, ...reasoning });
|
|
9302
|
+
const a = (r.content ?? "").trim().toLowerCase();
|
|
9303
|
+
return /^yes/.test(a) ? "keep" : /^no/.test(a) ? "cut" : null;
|
|
9304
|
+
};
|
|
9305
|
+
}
|
|
9306
|
+
var OverlapJudgeOptions = class {
|
|
9307
|
+
/** The fast model for ambiguous overlaps; absent → ambiguous cuts. */
|
|
9308
|
+
model;
|
|
9309
|
+
/** A model verdict later than this is dropped → cut. 600ms: groq qwen measured p50 ~100ms / max ~230ms in isolation
|
|
9310
|
+
* (24 calls, 2026-10-14) but live, beside the reflex + chatty-channel calls, 2 of 2 ambiguous verdicts missed 300ms
|
|
9311
|
+
* and cut the reply. The engine holds the overlap up to 900ms (bindOverlapInstinct) — a slightly later keep beats
|
|
9312
|
+
* a cut, so the budget sits well inside that hold. */
|
|
9313
|
+
budgetMs = 600;
|
|
9314
|
+
onDecision = () => {
|
|
9315
|
+
};
|
|
9316
|
+
now = () => performance.now();
|
|
9317
|
+
};
|
|
9318
|
+
var OverlapJudge = class {
|
|
9319
|
+
constructor(options) {
|
|
9320
|
+
this.options = options;
|
|
9321
|
+
this.options = { ...new OverlapJudgeOptions(), ...options };
|
|
9322
|
+
}
|
|
9323
|
+
options;
|
|
9324
|
+
get o() {
|
|
9325
|
+
return this.options;
|
|
9326
|
+
}
|
|
9327
|
+
async judge(heard) {
|
|
9328
|
+
const t0 = this.o.now();
|
|
9329
|
+
const done = (verdict, by) => {
|
|
9330
|
+
const d = { verdict, by, ms: Math.round(this.o.now() - t0) };
|
|
9331
|
+
if (by === "late" || by === "error" || by === "none") log20.warn(`overlap "${heard.slice(0, 60)}": no verdict (${by}, ${d.ms}ms) \u2192 cut`);
|
|
9332
|
+
else log20.debug(`overlap "${heard.slice(0, 60)}": ${verdict} by ${by} ${d.ms}ms`);
|
|
9333
|
+
this.o.onDecision(heard, d);
|
|
9334
|
+
return d;
|
|
9335
|
+
};
|
|
9336
|
+
const rule = overlapRule(heard);
|
|
9337
|
+
if (rule) return done(rule, "rule");
|
|
9338
|
+
if (!this.o.model) return done("cut", "none");
|
|
9339
|
+
const ctl = new AbortController();
|
|
9340
|
+
let timer;
|
|
9341
|
+
const late = new Promise((r) => {
|
|
9342
|
+
timer = setTimeout(() => {
|
|
9343
|
+
ctl.abort();
|
|
9344
|
+
r("late");
|
|
9345
|
+
}, this.o.budgetMs);
|
|
9346
|
+
});
|
|
9347
|
+
try {
|
|
9348
|
+
const v = await Promise.race([this.o.model(heard, ctl.signal).catch((e) => {
|
|
9349
|
+
if (!ctl.signal.aborted) log20.warn(`overlap model failed: ${e.message}`);
|
|
9350
|
+
return "error";
|
|
9351
|
+
}), late]);
|
|
9352
|
+
if (v === "late" || v === "error") return done("cut", v);
|
|
9353
|
+
return v ? done(v, "model") : done("cut", "error");
|
|
9354
|
+
} finally {
|
|
9355
|
+
clearTimeout(timer);
|
|
9356
|
+
}
|
|
9357
|
+
}
|
|
9358
|
+
};
|
|
9359
|
+
function overlapModelFor(ai, env = process.env) {
|
|
9360
|
+
const model = env.AGENTX_CHANNEL_DECIDER_MODEL || void 0;
|
|
9361
|
+
if (!model && !env.GROQ_API_KEY) return void 0;
|
|
9362
|
+
const m = fastOverlapModel(ai, model);
|
|
9363
|
+
const t0 = performance.now();
|
|
9364
|
+
void m("yeah", AbortSignal.timeout(1e4)).then(
|
|
9365
|
+
() => log20.debug(`fast model warm in ${Math.round(performance.now() - t0)}ms`),
|
|
9366
|
+
(e) => log20.warn(`fast model warm-up failed (the first overlap verdict may be late): ${e?.message ?? e}`)
|
|
9367
|
+
);
|
|
9368
|
+
return m;
|
|
9369
|
+
}
|
|
9237
9370
|
|
|
9238
9371
|
// src/voice/instinct.ts
|
|
9239
9372
|
var log21 = forComponent("Instinct");
|
|
@@ -9249,6 +9382,17 @@ var ACK_SITUATIONS = {
|
|
|
9249
9382
|
command: 'a request or instruction for you to do, find, check or tell them something ("tell me the news", "check my calendar")',
|
|
9250
9383
|
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")'
|
|
9251
9384
|
};
|
|
9385
|
+
var CHANNEL_ACTIONS = {
|
|
9386
|
+
silence: 'the default \u2014 nothing to add. ALWAYS for a greeting, thanks, a yes/no, or any short complete turn the real reply will answer instantly ("hey", "how are you?", "thanks"); also mid-word or mid-clause',
|
|
9387
|
+
backchannel: 'they are mid-way through a longer story or explanation (several clauses) and just closed a clause: a tiny "mm-hm"/"right" shows you follow, without taking the turn',
|
|
9388
|
+
filler: 'they paused mid-thought, searching for words, and a soft "hmm" keeps the line warm',
|
|
9389
|
+
bridge: 'they finished a substantial request or hard question and the real answer takes a moment: a short "let me check\u2014" lead-in'
|
|
9390
|
+
};
|
|
9391
|
+
var CHANNEL_BANK = {
|
|
9392
|
+
backchannel: ["Mm-hm.", "Right.", "Mm, yeah.", "Uh-huh.", "I see.", "Mm."],
|
|
9393
|
+
filler: ["Hmm\u2026", "Mm\u2026", "Hm, mm\u2026", "Ahh\u2026"],
|
|
9394
|
+
bridge: ["Let me check\u2014", "One sec, looking\u2014", "Hm, let me see\u2014", "Okay, give me a moment\u2014", "Let me think on that\u2014"]
|
|
9395
|
+
};
|
|
9252
9396
|
var INTENT_ACK_OVER = {
|
|
9253
9397
|
stop: Object.keys(ACK_SITUATIONS).filter((s) => s !== "silent"),
|
|
9254
9398
|
steer: ["command"],
|
|
@@ -9322,6 +9466,34 @@ async function unsaidClauses(pieces, spoken, decider, timeoutMs = 5e3) {
|
|
|
9322
9466
|
}
|
|
9323
9467
|
}
|
|
9324
9468
|
|
|
9469
|
+
// src/voice/spelling.ts
|
|
9470
|
+
var GROUP = String.raw`(?:\b[A-Za-z](?:-[A-Za-z]){1,}\b|\b[A-Z](?: [A-Z]){2,}\b)`;
|
|
9471
|
+
var SEP = String.raw`\s*(?:\b[Dd]ot\b|\b[Pp]eriod\b|\.)\s*`;
|
|
9472
|
+
var RUN = new RegExp(`${GROUP}(?:${SEP}${GROUP})*`, "g");
|
|
9473
|
+
function spelledOut(text) {
|
|
9474
|
+
const out = [];
|
|
9475
|
+
for (const m of text.matchAll(RUN)) {
|
|
9476
|
+
const raw = m[0];
|
|
9477
|
+
if (!/^[A-Za-z][- ]/.test(raw)) continue;
|
|
9478
|
+
const parts = raw.split(new RegExp(SEP)).map((g) => g.replace(/[- ]/g, ""));
|
|
9479
|
+
const word = parts.join(".");
|
|
9480
|
+
if (word.replace(/\./g, "").length < 3) continue;
|
|
9481
|
+
const s = parts.length > 1 ? word.toLowerCase() : word;
|
|
9482
|
+
if (!out.includes(s)) out.push(s);
|
|
9483
|
+
}
|
|
9484
|
+
return out;
|
|
9485
|
+
}
|
|
9486
|
+
function spellingNote(text) {
|
|
9487
|
+
const w = spelledOut(text);
|
|
9488
|
+
return w.length ? `
|
|
9489
|
+
[spelled out letter by letter: ${w.map((x) => `"${x}"`).join(", ")} \u2014 that spelling is exact and replaces the word you heard]` : "";
|
|
9490
|
+
}
|
|
9491
|
+
|
|
9492
|
+
// src/voice/channelLang.ts
|
|
9493
|
+
init_logging();
|
|
9494
|
+
import { mkdir, readFile, writeFile } from "fs/promises";
|
|
9495
|
+
import { join as join5 } from "path";
|
|
9496
|
+
|
|
9325
9497
|
// src/voice/leak.ts
|
|
9326
9498
|
var TOOL = String.raw`(?:QuickLook|TaskStatus|CancelTask|AnswerTask|ExitSession|Act|Think|Hold(?! on\b))`;
|
|
9327
9499
|
var SUSPECT = [
|
|
@@ -9399,13 +9571,480 @@ function scrubSpokenJunk(text, final = false) {
|
|
|
9399
9571
|
return s;
|
|
9400
9572
|
}
|
|
9401
9573
|
function isRepeatOf(sentence, spoken) {
|
|
9402
|
-
const
|
|
9403
|
-
const s =
|
|
9404
|
-
return s.replace(/ /g, "").length >= 16 && spoken.split(SENTENCE_SPLIT).some((x) =>
|
|
9574
|
+
const norm4 = (x) => x.replace(/\[[a-z_ ]+\]/gi, "").replace(/[^\p{L}\p{N}]+/gu, " ").trim().toLowerCase();
|
|
9575
|
+
const s = norm4(sentence);
|
|
9576
|
+
return s.replace(/ /g, "").length >= 16 && spoken.split(SENTENCE_SPLIT).some((x) => norm4(x) === s);
|
|
9405
9577
|
}
|
|
9578
|
+
var LEAD_TAGS = String.raw`^(?:\s*\[[^\]]*\]\s*)*\s*`;
|
|
9579
|
+
var RESULT_CLAIM_RE = new RegExp(LEAD_TAGS + String.raw`(?:(?:all\s+)?done|finished|(?:i\s+)?found|i(?:['’]ve|\s+have)?\s+(?:found|checked|looked|scanned|searched)|there\s+(?:are|is|were|was)\s+no|no\s+(?:files?|results?|matches|sessions?))\b`, "i");
|
|
9580
|
+
var DONE_CLAIM_RE = new RegExp(LEAD_TAGS + String.raw`(?:(?:all\s+)?done|finished|it['’]?s\s+(?:done|finished)|completed?)\b`, "i");
|
|
9581
|
+
|
|
9582
|
+
// src/voice/channelLang.ts
|
|
9583
|
+
var log23 = forComponent("ChannelBanks");
|
|
9584
|
+
var LINE_KINDS = ["backchannel", "filler", "bridge"];
|
|
9585
|
+
var BANK_KEYS = [...LINE_KINDS, "phatic"];
|
|
9586
|
+
var EN_PHATIC = [
|
|
9587
|
+
"hey",
|
|
9588
|
+
"hi",
|
|
9589
|
+
"hello",
|
|
9590
|
+
"yo",
|
|
9591
|
+
"hiya",
|
|
9592
|
+
"morning",
|
|
9593
|
+
"good morning",
|
|
9594
|
+
"good evening",
|
|
9595
|
+
"good afternoon",
|
|
9596
|
+
"thanks",
|
|
9597
|
+
"thank you",
|
|
9598
|
+
"ok",
|
|
9599
|
+
"okay",
|
|
9600
|
+
"yes",
|
|
9601
|
+
"yeah",
|
|
9602
|
+
"yep",
|
|
9603
|
+
"no",
|
|
9604
|
+
"nope",
|
|
9605
|
+
"sure",
|
|
9606
|
+
"bye",
|
|
9607
|
+
"goodbye",
|
|
9608
|
+
"how are you",
|
|
9609
|
+
"what's up",
|
|
9610
|
+
"whats up",
|
|
9611
|
+
"can you hear me"
|
|
9612
|
+
];
|
|
9613
|
+
var EN_BANK = { ...CHANNEL_BANK, phatic: EN_PHATIC };
|
|
9614
|
+
var HE_BANK = {
|
|
9615
|
+
backchannel: ["\u05D0\u05D4\u05D4", "\u05DB\u05DF", "\u05E0\u05DB\u05D5\u05DF", "\u05D0\u05D5\u05E7\u05D9\u05D9", "\u05DE\u05DE", "\u05D1\u05E8\u05D5\u05E8", "\u05D5\u05D5\u05D0\u05D5", "\u05D9\u05E4\u05D4", "\u05D1\u05D8\u05D7", "\u05D0\u05D4, \u05DB\u05DF"],
|
|
9616
|
+
filler: ["\u05DE\u05DE\u2026", "\u05D0\u05D4\u05D4\u2026", "\u05DB\u05DF\u2026", "\u05D0\u05DE\u05DE\u2026", "\u05DE\u05DE, \u05DB\u05DF\u2026", "\u05D0\u05D5\u05E7\u05D9\u05D9\u2026"],
|
|
9617
|
+
bridge: ["\u05E8\u05D2\u05E2\u2026", "\u05E9\u05E0\u05D9\u05D9\u05D4\u2026", "\u05E8\u05E7 \u05E9\u05E0\u05D9\u05D9\u05D4\u2026", "\u05E8\u05D2\u05E2 \u05D0\u05D7\u05D3\u2026", "\u05DE\u05D9\u05D3\u2026", "\u05E2\u05D5\u05D3 \u05E8\u05D2\u05E2\u2026", "\u05D0\u05D5\u05E7\u05D9\u05D9, \u05E8\u05D2\u05E2\u2026", "\u05E9\u05E0\u05D9\u05D9\u05D4, \u05E8\u05D2\u05E2\u2026"],
|
|
9618
|
+
phatic: [
|
|
9619
|
+
"\u05D4\u05D9\u05D9",
|
|
9620
|
+
"\u05D4\u05D9",
|
|
9621
|
+
"\u05E9\u05DC\u05D5\u05DD",
|
|
9622
|
+
"\u05D0\u05D4\u05DC\u05DF",
|
|
9623
|
+
"\u05DE\u05D4 \u05E0\u05E9\u05DE\u05E2",
|
|
9624
|
+
"\u05DE\u05D4 \u05E7\u05D5\u05E8\u05D4",
|
|
9625
|
+
"\u05EA\u05D5\u05D3\u05D4",
|
|
9626
|
+
"\u05EA\u05D5\u05D3\u05D4 \u05E8\u05D1\u05D4",
|
|
9627
|
+
"\u05DB\u05DF",
|
|
9628
|
+
"\u05DC\u05D0",
|
|
9629
|
+
"\u05D0\u05D5\u05E7\u05D9\u05D9",
|
|
9630
|
+
"\u05D1\u05E1\u05D3\u05E8",
|
|
9631
|
+
"\u05D1\u05D9\u05D9",
|
|
9632
|
+
"\u05DC\u05D4\u05EA\u05E8\u05D0\u05D5\u05EA",
|
|
9633
|
+
"\u05D1\u05D5\u05E7\u05E8 \u05D8\u05D5\u05D1",
|
|
9634
|
+
"\u05E2\u05E8\u05D1 \u05D8\u05D5\u05D1",
|
|
9635
|
+
"\u05D0\u05EA\u05D4 \u05E9\u05D5\u05DE\u05E2 \u05D0\u05D5\u05EA\u05D9",
|
|
9636
|
+
"\u05D0\u05EA \u05E9\u05D5\u05DE\u05E2\u05EA \u05D0\u05D5\u05EA\u05D9"
|
|
9637
|
+
]
|
|
9638
|
+
};
|
|
9639
|
+
var primaryLang = (tag) => tag.trim().toLowerCase().split(/[-_]/)[0];
|
|
9640
|
+
function isPhatic(text, bank) {
|
|
9641
|
+
const t = norm3(text);
|
|
9642
|
+
return !!t && !!bank?.phatic?.some((p) => {
|
|
9643
|
+
const q = norm3(p);
|
|
9644
|
+
return !!q && (t === q || t.startsWith(q + " "));
|
|
9645
|
+
});
|
|
9646
|
+
}
|
|
9647
|
+
var SKIP = new Set("um uh uhm erm hmm mm oh ok okay so no yes yeah yep well hey hi and but right alright actually like now then wait hang on me speaker \u05D0\u05D5\u05E7\u05D9\u05D9 \u05D8\u05D5\u05D1 \u05D0\u05D6 \u05DC\u05D0 \u05DB\u05DF \u05E8\u05D2\u05E2 \u05D0\u05D4 \u05D0\u05DE \u05D9\u05D0\u05DC\u05DC\u05D4 \u05D5".split(" "));
|
|
9648
|
+
var ASK = "what whats how why where when who which whose is are was were do does did can could would will should shall have has may isnt arent dont doesnt didnt cant wont check open show tell find search look send run go lets please take give make write explain select focus order try compare list use set activate highlight outline capture start stop read play call book create add remove delete put type click close switch help get summarize translate mark keep remind schedule \u05DE\u05D4 \u05D0\u05D9\u05DA \u05DC\u05DE\u05D4 \u05D0\u05D9\u05E4\u05D4 \u05DE\u05EA\u05D9 \u05DE\u05D9 \u05D4\u05D0\u05DD \u05DB\u05DE\u05D4 \u05EA\u05DF \u05EA\u05E0\u05D9 \u05EA\u05D2\u05D9\u05D3 \u05EA\u05D2\u05D9\u05D3\u05D9 \u05D1\u05D5\u05D0 \u05D1\u05D5\u05D0\u05D9".split(" ");
|
|
9649
|
+
var WANT = /^(i (want|need|would like|d like|wanna)|אני (רוצה|צריך|צריכה|מבקש|מבקשת))(\s|$)/u;
|
|
9650
|
+
var lead = (text) => {
|
|
9651
|
+
const w = text.toLowerCase().replace(/[^\p{L}\p{N}\s]+/gu, "").split(/\s+/).filter(Boolean);
|
|
9652
|
+
let i = 0;
|
|
9653
|
+
while (i < w.length && (SKIP.has(w[i]) || /^\d+$/.test(w[i]))) i++;
|
|
9654
|
+
return w.slice(i);
|
|
9655
|
+
};
|
|
9656
|
+
function requestOpener(text) {
|
|
9657
|
+
const w = lead(text);
|
|
9658
|
+
if (!w.length) return false;
|
|
9659
|
+
return ASK.includes(w[0]) || WANT.test(w.slice(0, 4).join(" ")) || new RegExp("^\u05EA\\p{Script=Hebrew}{2,}$", "u").test(w[0]);
|
|
9660
|
+
}
|
|
9661
|
+
function pendingQuestion(text, lastWords = 6) {
|
|
9662
|
+
const all = text.toLowerCase().replace(/[^\p{L}\p{N}'\s.!?]+/gu, " ");
|
|
9663
|
+
const sentence = all.split(/[.!?]/).at(-1).split(/\s+/).filter(Boolean);
|
|
9664
|
+
const t = sentence.slice(-lastWords).join(" ");
|
|
9665
|
+
return /\b(can|could|would|will|do|does|did|is|are|should)\s+(you|we|i|it|there|they|he|she)\b/.test(t) || /(^|\s)(האם|אתה יכול|את יכולה)(\s|$)/u.test(t) || /\b(what|why|how|where|when|who|which)\s+(do|does|did|is|are|should|would|can|could|will)\s+(you|we|i|it|they|he|she)\b/.test(sentence.slice(-4 * lastWords).join(" "));
|
|
9666
|
+
}
|
|
9667
|
+
var SCRIPTS = [
|
|
9668
|
+
[new RegExp("\\p{Script=Hebrew}", "gu"), "he"],
|
|
9669
|
+
[new RegExp("\\p{Script=Arabic}", "gu"), "ar"],
|
|
9670
|
+
[new RegExp("\\p{Script=Cyrillic}", "gu"), "ru"],
|
|
9671
|
+
[new RegExp("\\p{Script=Greek}", "gu"), "el"],
|
|
9672
|
+
[/[\p{Script=Hiragana}\p{Script=Katakana}]/gu, "ja"],
|
|
9673
|
+
[new RegExp("\\p{Script=Hangul}", "gu"), "ko"],
|
|
9674
|
+
[new RegExp("\\p{Script=Han}", "gu"), "zh"],
|
|
9675
|
+
[new RegExp("\\p{Script=Thai}", "gu"), "th"],
|
|
9676
|
+
[new RegExp("\\p{Script=Devanagari}", "gu"), "hi"],
|
|
9677
|
+
[new RegExp("\\p{Script=Latin}", "gu"), "latin"]
|
|
9678
|
+
];
|
|
9679
|
+
var STOP3 = Object.fromEntries(Object.entries({
|
|
9680
|
+
en: "hi hello hey yes no a thanks let see check one sec just give right well look the and is are you i it to of that what this was have with for not but my me",
|
|
9681
|
+
// 'a'/'no' are English too: without them 'thanks a lot', 'long time no see' failed fitsLanguage('en')
|
|
9682
|
+
es: "hola s\xED gracias bueno a me te lo se le del al el la los las que es y de en un una por para con no pero mi yo qu\xE9 est\xE1",
|
|
9683
|
+
fr: "bonjour salut oui merci le la les est et de des un une que je tu il pas pour avec mais mon qui",
|
|
9684
|
+
de: "hallo ja danke der die das und ist ich du nicht ein eine mit zu was aber mein wie auch",
|
|
9685
|
+
it: "ciao grazie s\xEC a mi ti si il lo la gli che \xE8 e di un una non per con ma mio io sono",
|
|
9686
|
+
pt: "ol\xE1 oi sim obrigado obrigada o a os as que \xE9 e de em um uma n\xE3o para com mas meu eu voc\xEA",
|
|
9687
|
+
nl: "hoi dank de het een en is ik je niet van dat met voor maar mijn wat"
|
|
9688
|
+
}).map(([k, v]) => [k, new Set(v.split(" "))]));
|
|
9689
|
+
var scriptOf = (w) => SCRIPTS.find(([re]) => {
|
|
9690
|
+
re.lastIndex = 0;
|
|
9691
|
+
return re.test(w);
|
|
9692
|
+
})?.[1] ?? null;
|
|
9693
|
+
function detectLanguage(text, switching = false) {
|
|
9694
|
+
const ws = text.toLowerCase().match(new RegExp("\\p{L}+", "gu")) ?? [];
|
|
9695
|
+
const n = /* @__PURE__ */ new Map();
|
|
9696
|
+
for (const w of ws) {
|
|
9697
|
+
const s = scriptOf(w);
|
|
9698
|
+
if (s) n.set(s, (n.get(s) ?? 0) + 1);
|
|
9699
|
+
}
|
|
9700
|
+
const [best, c] = [...n].sort((a2, b2) => b2[1] - a2[1])[0] ?? [null, 0];
|
|
9701
|
+
if (best !== "latin") return c >= 1 && (best === "zh" || best === "ja" || c >= 2 || ws.length === 1 && !switching) ? best : null;
|
|
9702
|
+
const score = Object.entries(STOP3).map(([l2, s]) => [l2, ws.filter((w) => s.has(w)).length]).sort((a2, b2) => b2[1] - a2[1]);
|
|
9703
|
+
const [[l, a], [, b]] = score;
|
|
9704
|
+
if (switching) return a - b >= 2 && ws.length >= 4 ? l : null;
|
|
9705
|
+
return a > b && (a >= 2 || a === 1 && ws.length <= 3) ? l : null;
|
|
9706
|
+
}
|
|
9707
|
+
var NAMES = new Intl.DisplayNames(["en"], { type: "language" });
|
|
9708
|
+
var languageName = (lang) => {
|
|
9709
|
+
try {
|
|
9710
|
+
return NAMES.of(lang) ?? lang;
|
|
9711
|
+
} catch {
|
|
9712
|
+
return lang;
|
|
9713
|
+
}
|
|
9714
|
+
};
|
|
9715
|
+
function fitsLanguage(line, lang) {
|
|
9716
|
+
const own = SCRIPTS.find(([, l]) => l === lang)?.[0];
|
|
9717
|
+
if (own) return (line.match(own)?.length ?? 0) > 0 && !new RegExp("\\p{Script=Latin}{2,}", "u").test(line);
|
|
9718
|
+
if (/[^\p{Script=Latin}\p{M}\p{P}\p{Z}\p{N}\p{S}]/u.test(line)) return false;
|
|
9719
|
+
const mine = STOP3[lang];
|
|
9720
|
+
const ws = line.toLowerCase().match(new RegExp("\\p{L}+", "gu")) ?? [];
|
|
9721
|
+
return !ws.some((w) => !mine?.has(w) && Object.entries(STOP3).some(([l, s]) => l !== lang && s.has(w)));
|
|
9722
|
+
}
|
|
9723
|
+
var norm3 = (s) => s.toLowerCase().replace(/[^\p{L}\p{N}' ]/gu, "").replace(/\s+/g, " ").trim();
|
|
9724
|
+
function cleanLine(s, lang, maxWords = 6) {
|
|
9725
|
+
const raw = s.trim().split("\n")[0];
|
|
9726
|
+
const l = raw.replace(/^[-*•\d.)\s]+/, "").replace(/^["'“”«»]+|["'“”«»]+$/g, "").replace(new RegExp("^\\p{L}+:\\s*", "u"), "").trim();
|
|
9727
|
+
if (!l || isLeakSuspect(raw) || isLeakSuspect(l)) {
|
|
9728
|
+
if (l) log23.debug(`line "${raw}" reads as meta \u2014 rejected`);
|
|
9729
|
+
return "";
|
|
9730
|
+
}
|
|
9731
|
+
if (lang && !fitsLanguage(l, lang)) {
|
|
9732
|
+
log23.debug(`line "${l}" is not ${lang} \u2014 rejected`);
|
|
9733
|
+
return "";
|
|
9734
|
+
}
|
|
9735
|
+
return l.split(/\s+/).length > maxWords || l.length > 60 ? "" : l;
|
|
9736
|
+
}
|
|
9737
|
+
var SPEAKER_FILLER = /^(?:so|well|actually|basically|anyway|anyways|like|you know|i mean|kind of|sort of|literally|honestly|and|but|because|then|the thing is)\b/i;
|
|
9738
|
+
function validateBank(raw, lang, minPerKind = 3) {
|
|
9739
|
+
if (!raw || typeof raw !== "object") return null;
|
|
9740
|
+
const out = {};
|
|
9741
|
+
for (const k of BANK_KEYS) {
|
|
9742
|
+
const v = raw[k];
|
|
9743
|
+
const seen = /* @__PURE__ */ new Set();
|
|
9744
|
+
out[k] = (Array.isArray(v) ? v : []).filter((x) => typeof x === "string").map((x) => cleanLine(x, lang)).filter((x) => x && !(k === "filler" && SPEAKER_FILLER.test(norm3(x))) && !seen.has(norm3(x)) && seen.add(norm3(x)));
|
|
9745
|
+
if (out[k].length < minPerKind) {
|
|
9746
|
+
log23.warn(`bank ${lang}: only ${out[k].length} valid ${k} lines \u2014 rejected`);
|
|
9747
|
+
return null;
|
|
9748
|
+
}
|
|
9749
|
+
}
|
|
9750
|
+
return out;
|
|
9751
|
+
}
|
|
9752
|
+
var KIND_HINT = {
|
|
9753
|
+
backchannel: 'tiny listener cues while they keep talking (like "mm-hm", "right") \u2014 never takes the turn',
|
|
9754
|
+
filler: 'soft LISTENER sounds while they pause mid-thought (like "mm\u2026", "mhm", "yeah\u2026") \u2014 never a word that continues or completes their sentence ("kind of", "basically", "you know") and never the start of yours',
|
|
9755
|
+
bridge: `neutral one-moment cues while the answer is prepared (like "let me see\u2026", "one sec\u2026") \u2014 never the start of a statement or opinion ("the thing is", "well actually", "here's the deal")`,
|
|
9756
|
+
phatic: 'greetings, thanks and yes/no openers a person starts a turn with (like "hi", "thanks", "how are you")'
|
|
9757
|
+
};
|
|
9758
|
+
function fastBankGenerator(ai, model, perKind = 12) {
|
|
9759
|
+
return async (lang, avoid, signal) => {
|
|
9760
|
+
const name = languageName(lang);
|
|
9761
|
+
const prompt = `Write short spoken side-sounds for a live voice conversation, in ${name} (natural, casual, how native ${name} speakers really talk \u2014 not translated English). ${perKind} varied lines per kind, 1-4 words each \u2014 short, idiomatic, grammatical everyday phrases a native would actually say:
|
|
9762
|
+
${BANK_KEYS.map((k) => `- ${k}: ${KIND_HINT[k]}`).join("\n")}
|
|
9763
|
+
` + (avoid.length ? `Do not repeat these existing ones: ${avoid.slice(0, 60).join(" | ")}
|
|
9764
|
+
` : "") + `Reply with JSON only: {"backchannel":[...],"filler":[...],"bridge":[...],"phatic":[...]}. Every line in ${name}; no explanations.`;
|
|
9765
|
+
const reasoning = /gpt-oss/.test(model) ? { providerOptions: { reasoning_effort: "low" } } : {};
|
|
9766
|
+
const r = await ai.chat({ model, messages: [{ role: "user", content: prompt }], stream: false, signal, maxTokens: 2e3, ...reasoning });
|
|
9767
|
+
const m = (r.content ?? "").match(/\{[\s\S]*\}/);
|
|
9768
|
+
if (!m) throw new Error(`no JSON in bank reply: ${(r.content ?? "").slice(0, 80)}`);
|
|
9769
|
+
return JSON.parse(m[0]);
|
|
9770
|
+
};
|
|
9771
|
+
}
|
|
9772
|
+
var ChannelBanksOptions = class {
|
|
9773
|
+
/** Raw bank for a language (fastBankGenerator). Absent → English fallback only. */
|
|
9774
|
+
generate;
|
|
9775
|
+
/** Disk cache dir (one `<lang>.json` per language). Absent → in-memory only. */
|
|
9776
|
+
cacheDir;
|
|
9777
|
+
/** A cached bank older than this is still used, and topped up by one background call. */
|
|
9778
|
+
refreshMs = 24 * 36e5;
|
|
9779
|
+
/** Lines kept per kind (newest win) — the cap on growth. */
|
|
9780
|
+
maxPerKind = 40;
|
|
9781
|
+
timeoutMs = 2e4;
|
|
9782
|
+
/** After a failed cold generation, the next attempt waits this long. */
|
|
9783
|
+
retryMs = 6e4;
|
|
9784
|
+
/** Cold-start/offline banks (English only by default). */
|
|
9785
|
+
fallback = { en: EN_BANK };
|
|
9786
|
+
/** Hand-checked banks: used as-is for their language — never generated, cached or topped up. */
|
|
9787
|
+
curated = { he: HE_BANK };
|
|
9788
|
+
now = () => Date.now();
|
|
9789
|
+
};
|
|
9790
|
+
var BANK_VERSION = 2;
|
|
9791
|
+
var ChannelBanks = class {
|
|
9792
|
+
constructor(options) {
|
|
9793
|
+
this.options = options;
|
|
9794
|
+
this.options = { ...new ChannelBanksOptions(), ...options };
|
|
9795
|
+
}
|
|
9796
|
+
options;
|
|
9797
|
+
banks = /* @__PURE__ */ new Map();
|
|
9798
|
+
pending = /* @__PURE__ */ new Map();
|
|
9799
|
+
get o() {
|
|
9800
|
+
return this.options;
|
|
9801
|
+
}
|
|
9802
|
+
/** The bank to speak from for `lang` now — generated/cached, else the fallback, else null (= stay silent). */
|
|
9803
|
+
get(lang) {
|
|
9804
|
+
return this.o.curated[lang] ?? this.banks.get(lang) ?? this.o.fallback[lang] ?? null;
|
|
9805
|
+
}
|
|
9806
|
+
/** Load (cache) and/or generate `lang`'s bank in the background. Never throws, never blocks the caller. */
|
|
9807
|
+
ensure(lang) {
|
|
9808
|
+
if (this.o.curated[lang]) return Promise.resolve();
|
|
9809
|
+
if (!this.pending.has(lang)) this.pending.set(lang, this.load(lang));
|
|
9810
|
+
return this.pending.get(lang);
|
|
9811
|
+
}
|
|
9812
|
+
file(lang) {
|
|
9813
|
+
return this.o.cacheDir ? join5(this.o.cacheDir, `${lang.replace(/[^\w-]/g, "")}.json`) : null;
|
|
9814
|
+
}
|
|
9815
|
+
async load(lang) {
|
|
9816
|
+
const f = this.file(lang);
|
|
9817
|
+
let cached = null;
|
|
9818
|
+
if (f) cached = await readFile(f, "utf8").then((s) => JSON.parse(s)).catch((e) => {
|
|
9819
|
+
if (e.code !== "ENOENT") log23.warn(`bank cache ${f} unreadable \u2014 regenerating: ${e.message}`);
|
|
9820
|
+
return null;
|
|
9821
|
+
});
|
|
9822
|
+
if (cached && cached.v !== BANK_VERSION) {
|
|
9823
|
+
log23.debug(`bank ${lang}: cache v${cached.v ?? 1} predates v${BANK_VERSION} \u2014 regenerating`);
|
|
9824
|
+
cached = null;
|
|
9825
|
+
}
|
|
9826
|
+
const valid = cached && validateBank(cached.bank, lang, 0);
|
|
9827
|
+
const complete = !!valid && BANK_KEYS.every((k) => valid[k].length);
|
|
9828
|
+
if (valid) {
|
|
9829
|
+
for (const k of BANK_KEYS) if (!valid[k].length) valid[k] = [...this.o.fallback[lang]?.[k] ?? []];
|
|
9830
|
+
this.banks.set(lang, valid);
|
|
9831
|
+
log23.debug(`bank ${lang}: cache hit (${BANK_KEYS.map((k) => valid[k].length).join("/")})${complete ? "" : " \u2014 missing kinds, refreshing"}`);
|
|
9832
|
+
}
|
|
9833
|
+
if (complete && this.o.now() - cached.at < this.o.refreshMs) return;
|
|
9834
|
+
await this.generate(lang, valid);
|
|
9835
|
+
}
|
|
9836
|
+
async generate(lang, have) {
|
|
9837
|
+
if (!this.o.generate) return;
|
|
9838
|
+
const ac = new AbortController();
|
|
9839
|
+
const t = setTimeout(() => ac.abort(), this.o.timeoutMs);
|
|
9840
|
+
const t0 = this.o.now();
|
|
9841
|
+
try {
|
|
9842
|
+
const fresh = validateBank(await this.o.generate(lang, have ? BANK_KEYS.flatMap((k) => have[k]) : [], ac.signal), lang);
|
|
9843
|
+
if (!fresh) return;
|
|
9844
|
+
const bank = {};
|
|
9845
|
+
for (const k of BANK_KEYS) {
|
|
9846
|
+
const seen = /* @__PURE__ */ new Set();
|
|
9847
|
+
bank[k] = [...fresh[k], ...have?.[k] ?? []].filter((x) => !seen.has(norm3(x)) && seen.add(norm3(x))).slice(0, this.o.maxPerKind);
|
|
9848
|
+
}
|
|
9849
|
+
this.banks.set(lang, bank);
|
|
9850
|
+
log23.debug(`bank ${lang}: ${have ? "refreshed" : "generated"} (${BANK_KEYS.map((k) => bank[k].length).join("/")}) in ${Math.round(this.o.now() - t0)}ms`);
|
|
9851
|
+
const f = this.file(lang);
|
|
9852
|
+
if (f) await mkdir(this.o.cacheDir, { recursive: true }).then(() => writeFile(f, JSON.stringify({ at: this.o.now(), v: BANK_VERSION, bank }))).catch((e) => log23.warn(`bank cache write ${f} failed: ${e.message}`));
|
|
9853
|
+
} catch (e) {
|
|
9854
|
+
log23.warn(`bank ${lang}: generation failed \u2014 ${have ? "keeping the cached bank" : lang in this.o.fallback ? "fallback bank" : "silent"}: ${e.message}`);
|
|
9855
|
+
} finally {
|
|
9856
|
+
clearTimeout(t);
|
|
9857
|
+
if (!this.banks.has(lang)) setTimeout(() => this.pending.delete(lang), this.o.retryMs).unref?.();
|
|
9858
|
+
}
|
|
9859
|
+
}
|
|
9860
|
+
};
|
|
9861
|
+
|
|
9862
|
+
// src/voice/turnGate.ts
|
|
9863
|
+
var MID_PHRASE_WORDS = /* @__PURE__ */ new Set([
|
|
9864
|
+
"slash",
|
|
9865
|
+
"backslash",
|
|
9866
|
+
"dash",
|
|
9867
|
+
"hyphen",
|
|
9868
|
+
"dot",
|
|
9869
|
+
"underscore",
|
|
9870
|
+
"colon",
|
|
9871
|
+
"comma",
|
|
9872
|
+
"and",
|
|
9873
|
+
"or",
|
|
9874
|
+
"but",
|
|
9875
|
+
"so",
|
|
9876
|
+
"because",
|
|
9877
|
+
"if",
|
|
9878
|
+
"then",
|
|
9879
|
+
"called",
|
|
9880
|
+
"named",
|
|
9881
|
+
"the",
|
|
9882
|
+
"a",
|
|
9883
|
+
"an",
|
|
9884
|
+
"my",
|
|
9885
|
+
"your",
|
|
9886
|
+
"our",
|
|
9887
|
+
"their",
|
|
9888
|
+
"is",
|
|
9889
|
+
"are",
|
|
9890
|
+
"um",
|
|
9891
|
+
"uh",
|
|
9892
|
+
// Hebrew connectives/prepositions a request cannot end on ("תפתח את", "ואז")
|
|
9893
|
+
"\u05E9\u05DC",
|
|
9894
|
+
"\u05D0\u05EA",
|
|
9895
|
+
"\u05E2\u05DD",
|
|
9896
|
+
"\u05E2\u05DC",
|
|
9897
|
+
"\u05D0\u05D1\u05DC",
|
|
9898
|
+
"\u05D0\u05D5",
|
|
9899
|
+
"\u05DB\u05D9",
|
|
9900
|
+
"\u05D0\u05DD",
|
|
9901
|
+
"\u05D5\u05D0\u05D6",
|
|
9902
|
+
"\u05D5\u05D2\u05DD",
|
|
9903
|
+
"\u05DB\u05DE\u05D5",
|
|
9904
|
+
"\u05DC\u05D2\u05D1\u05D9",
|
|
9905
|
+
"\u05D1\u05E9\u05D1\u05D9\u05DC",
|
|
9906
|
+
"\u05D0\u05DC",
|
|
9907
|
+
"\u05E2\u05D3",
|
|
9908
|
+
"\u05D0\u05D7\u05E8\u05D9",
|
|
9909
|
+
"\u05DC\u05E4\u05E0\u05D9",
|
|
9910
|
+
"\u05E9\u05D6\u05D4",
|
|
9911
|
+
"\u05D0\u05D4",
|
|
9912
|
+
"\u05D0\u05DE\u05DE"
|
|
9913
|
+
]);
|
|
9914
|
+
var TRAILING_PREPS = /* @__PURE__ */ new Set(["to", "for", "with", "about", "of", "at", "in", "on", "from", "by", "into", "under", "inside"]);
|
|
9915
|
+
var QUESTION_START = /^(?:what|who|whom|whose|where|when|why|how|which|is|are|was|were|do|does|did|can|could|will|would|should|have|has)\b/i;
|
|
9916
|
+
var PHRASAL_HOST = /* @__PURE__ */ new Set(["it", "this", "that", "them", "him", "her", "me", "us", "you", "up", "out", "off", "down", "back", "over", "away"]);
|
|
9917
|
+
function endsMidPhrase(text) {
|
|
9918
|
+
const t = text.split("\n[")[0].trim();
|
|
9919
|
+
if (!t || /[.?!…]["')\]]*$/.test(t)) return false;
|
|
9920
|
+
if (/\b[a-z][\w+.-]*:\/\/\S+$/i.test(t)) return false;
|
|
9921
|
+
if (/[,/\\:\-–—]$/.test(t)) return true;
|
|
9922
|
+
const [last, prev] = (t.toLowerCase().match(/[\p{L}']+/gu) ?? []).reverse();
|
|
9923
|
+
if (!last) return false;
|
|
9924
|
+
if (TRAILING_PREPS.has(last)) return !QUESTION_START.test(t) && !PHRASAL_HOST.has(prev);
|
|
9925
|
+
return MID_PHRASE_WORDS.has(last);
|
|
9926
|
+
}
|
|
9927
|
+
var words = (s) => s.trim() ? s.trim().split(/\s+/) : [];
|
|
9928
|
+
function isClosing(text) {
|
|
9929
|
+
return words(text).length <= 8 && /(?:^|[^\p{L}])(?:good ?bye|bye|see you|good ?night|talk (?:to you )?later|hang up|end (?:the )?call|ביי|להתראות|נתראה|לילה טוב|יאללה ביי)(?:$|[^\p{L}])/iu.test(text);
|
|
9930
|
+
}
|
|
9931
|
+
var TurnGateOptions = class {
|
|
9932
|
+
holdMs = 1200;
|
|
9933
|
+
/** An identical final within this window is a duplicate. */
|
|
9934
|
+
dupWindowMs = 5e3;
|
|
9935
|
+
/** A fuller final within this window of the last turn (or with its sttId) is a re-final of it. */
|
|
9936
|
+
refinalWindowMs = 5e3;
|
|
9937
|
+
onTurn = () => {
|
|
9938
|
+
};
|
|
9939
|
+
onDrop = () => {
|
|
9940
|
+
};
|
|
9941
|
+
/** A final is being held (ends mid-phrase) — the fill channel may cover it with a filler, never a bridge. */
|
|
9942
|
+
onHold = () => {
|
|
9943
|
+
};
|
|
9944
|
+
now = () => performance.now();
|
|
9945
|
+
};
|
|
9946
|
+
var TurnGate = class {
|
|
9947
|
+
constructor(options) {
|
|
9948
|
+
this.options = options;
|
|
9949
|
+
this.options = { ...new TurnGateOptions(), ...options };
|
|
9950
|
+
}
|
|
9951
|
+
options;
|
|
9952
|
+
last;
|
|
9953
|
+
held;
|
|
9954
|
+
seq = 0;
|
|
9955
|
+
closed = false;
|
|
9956
|
+
get o() {
|
|
9957
|
+
return this.options;
|
|
9958
|
+
}
|
|
9959
|
+
/** A closing turn went out — the call is ending. */
|
|
9960
|
+
get ending() {
|
|
9961
|
+
return this.closed;
|
|
9962
|
+
}
|
|
9963
|
+
/** Is a final held right now? */
|
|
9964
|
+
get holding() {
|
|
9965
|
+
return !!this.held;
|
|
9966
|
+
}
|
|
9967
|
+
/** The last turn sent (id + text), if any. */
|
|
9968
|
+
get lastTurn() {
|
|
9969
|
+
return this.last && { id: this.last.id, text: this.last.text, started: this.last.started };
|
|
9970
|
+
}
|
|
9971
|
+
/** An STT final. */
|
|
9972
|
+
final(text, sttId) {
|
|
9973
|
+
let t = text.trim();
|
|
9974
|
+
if (!t) return this.o.onDrop(text, "empty");
|
|
9975
|
+
if (this.held) {
|
|
9976
|
+
const h = this.held;
|
|
9977
|
+
clearTimeout(h.timer);
|
|
9978
|
+
this.held = void 0;
|
|
9979
|
+
if (!(norm3(t) + " ").startsWith(norm3(h.text) + " ")) t = `${h.text} ${t}`;
|
|
9980
|
+
sttId ??= h.sttId;
|
|
9981
|
+
}
|
|
9982
|
+
const key = norm3(t), now3 = this.o.now(), last = this.last;
|
|
9983
|
+
if (last) {
|
|
9984
|
+
const age = now3 - last.at;
|
|
9985
|
+
if (key === last.key && age < this.o.dupWindowMs) return this.o.onDrop(t, "dupe");
|
|
9986
|
+
const sameStt = !!sttId && sttId === last.sttId;
|
|
9987
|
+
if ((sameStt || age < this.o.refinalWindowMs) && key && (last.key + " ").startsWith(key + " ")) return this.o.onDrop(t, "stale");
|
|
9988
|
+
if (sameStt || age < this.o.refinalWindowMs && key.startsWith(last.key + " ")) return this.refinal(last, t, key, sttId);
|
|
9989
|
+
}
|
|
9990
|
+
if (this.closed && isClosing(t)) return this.o.onDrop(t, "goodbye");
|
|
9991
|
+
if (endsMidPhrase(t)) {
|
|
9992
|
+
this.held = { text: t, sttId, timer: setTimeout(() => this.flush(), this.o.holdMs) };
|
|
9993
|
+
return this.o.onHold(t);
|
|
9994
|
+
}
|
|
9995
|
+
this.send(t, key, sttId, "new");
|
|
9996
|
+
}
|
|
9997
|
+
/** The user is speaking again (a partial): a held fragment waits for the rest. */
|
|
9998
|
+
speaking() {
|
|
9999
|
+
if (!this.held) return;
|
|
10000
|
+
clearTimeout(this.held.timer);
|
|
10001
|
+
this.held.timer = setTimeout(() => this.flush(), this.o.holdMs);
|
|
10002
|
+
}
|
|
10003
|
+
/** MAIN started replying to turn `id` (its first text/tool/audio): a later re-final only adds its new words. */
|
|
10004
|
+
replyStarted(id) {
|
|
10005
|
+
if (this.last?.id === id) this.last.started = true;
|
|
10006
|
+
}
|
|
10007
|
+
/** Send a held fragment now (e.g. the call ends). */
|
|
10008
|
+
flush() {
|
|
10009
|
+
const h = this.held;
|
|
10010
|
+
if (!h) return;
|
|
10011
|
+
clearTimeout(h.timer);
|
|
10012
|
+
this.held = void 0;
|
|
10013
|
+
if (this.closed && isClosing(h.text)) return this.o.onDrop(h.text, "goodbye");
|
|
10014
|
+
this.send(h.text, norm3(h.text), h.sttId, "new");
|
|
10015
|
+
}
|
|
10016
|
+
end() {
|
|
10017
|
+
if (this.held) {
|
|
10018
|
+
clearTimeout(this.held.timer);
|
|
10019
|
+
this.held = void 0;
|
|
10020
|
+
}
|
|
10021
|
+
}
|
|
10022
|
+
refinal(last, t, key, sttId) {
|
|
10023
|
+
if (!last.started) return this.send(t, key, sttId ?? last.sttId, "replace", last.id);
|
|
10024
|
+
const a = words(last.text), b = words(t);
|
|
10025
|
+
let i = 0;
|
|
10026
|
+
while (i < a.length && i < b.length && norm3(a[i]) === norm3(b[i])) i++;
|
|
10027
|
+
const extra = b.slice(i).join(" ");
|
|
10028
|
+
if (!norm3(extra)) return this.o.onDrop(t, "stale");
|
|
10029
|
+
if (this.closed && isClosing(extra)) return this.o.onDrop(t, "goodbye");
|
|
10030
|
+
this.last = { ...last, text: t, key, at: this.o.now() };
|
|
10031
|
+
this.emit(extra, "extend");
|
|
10032
|
+
}
|
|
10033
|
+
send(t, key, sttId, kind, replaces) {
|
|
10034
|
+
const id = this.emit(t, kind, replaces);
|
|
10035
|
+
this.last = { id, text: t, key, sttId, at: this.o.now(), started: false };
|
|
10036
|
+
}
|
|
10037
|
+
emit(text, kind, replaces) {
|
|
10038
|
+
const id = `u${++this.seq}`;
|
|
10039
|
+
if (isClosing(text)) this.closed = true;
|
|
10040
|
+
if (this.last && kind === "extend") this.last.id = id;
|
|
10041
|
+
this.o.onTurn({ id, text, kind, ...replaces ? { replaces } : {} });
|
|
10042
|
+
return id;
|
|
10043
|
+
}
|
|
10044
|
+
};
|
|
9406
10045
|
|
|
9407
10046
|
// src/duplex.ts
|
|
9408
|
-
var
|
|
10047
|
+
var log24 = forComponent("DuplexAgent");
|
|
9409
10048
|
function describeCall(call) {
|
|
9410
10049
|
const v = call.args && Object.values(call.args).find((x) => typeof x === "string" && x.trim());
|
|
9411
10050
|
const hint = v ? ` (${String(v).replace(/\s+/g, " ").trim().slice(0, 48)})` : "";
|
|
@@ -9418,12 +10057,19 @@ function refusedCall(e) {
|
|
|
9418
10057
|
function rateLimited(e) {
|
|
9419
10058
|
return Number(e?.statusCode ?? e?.status) === 429 || /rate.?limit|RATE_LIMIT|tokens per minute|too many requests/i.test(String(e?.message ?? e?.code ?? e ?? ""));
|
|
9420
10059
|
}
|
|
9421
|
-
var
|
|
9422
|
-
|
|
9423
|
-
|
|
10060
|
+
var bare = (s) => s.replace(/\[[^\]]*\]/g, " ").replace(/[\s\u00a0]+/g, " ").trim().toLowerCase();
|
|
10061
|
+
function restarts(stub, next) {
|
|
10062
|
+
const w = bare(stub).match(/^[\p{L}\p{N}'’]+/u)?.[0];
|
|
10063
|
+
return !!w && bare(next).startsWith(w);
|
|
10064
|
+
}
|
|
10065
|
+
function isFalseStart(sentence) {
|
|
10066
|
+
const s = sentence.replace(/\[[^\]]*\]/g, " ").replace(/[\s\u00a0]+/g, " ").trim();
|
|
10067
|
+
const m = s.match(/^([\p{L}\p{N}'’]+(?: [\p{L}\p{N}'’,]+)?)\s*(?:…|\.{2,})[\s.…]*$/u);
|
|
10068
|
+
return !!m && !/^(?:h+m+|u+m+|u+h+|o+h+|a+h+|well|so|er+m*)$/i.test(m[1]);
|
|
10069
|
+
}
|
|
9424
10070
|
function wordOverlap(sentence, prior) {
|
|
9425
|
-
const
|
|
9426
|
-
const have = new Set(
|
|
10071
|
+
const words3 = (x) => x.replace(/\[[^\]]*\]/g, " ").toLowerCase().replace(/['’]/g, "").match(new RegExp("\\p{L}{4,}", "gu")) ?? [];
|
|
10072
|
+
const have = new Set(words3(prior)), ws = words3(sentence);
|
|
9427
10073
|
return ws.length ? ws.filter((w) => have.has(w)).length / ws.length : 0;
|
|
9428
10074
|
}
|
|
9429
10075
|
var DuplexAgentOptions = class {
|
|
@@ -9517,49 +10163,6 @@ var DuplexAgentOptions = class {
|
|
|
9517
10163
|
memoryUserDir;
|
|
9518
10164
|
};
|
|
9519
10165
|
var SESSION_ID_RE = /^\d{8}-\d{6}(?:-[\w-]+)?$/;
|
|
9520
|
-
var MID_PHRASE_WORDS = /* @__PURE__ */ new Set([
|
|
9521
|
-
"slash",
|
|
9522
|
-
"backslash",
|
|
9523
|
-
"dash",
|
|
9524
|
-
"hyphen",
|
|
9525
|
-
"dot",
|
|
9526
|
-
"underscore",
|
|
9527
|
-
"colon",
|
|
9528
|
-
"comma",
|
|
9529
|
-
"and",
|
|
9530
|
-
"or",
|
|
9531
|
-
"but",
|
|
9532
|
-
"so",
|
|
9533
|
-
"because",
|
|
9534
|
-
"if",
|
|
9535
|
-
"then",
|
|
9536
|
-
"called",
|
|
9537
|
-
"named",
|
|
9538
|
-
"the",
|
|
9539
|
-
"a",
|
|
9540
|
-
"an",
|
|
9541
|
-
"my",
|
|
9542
|
-
"your",
|
|
9543
|
-
"our",
|
|
9544
|
-
"their",
|
|
9545
|
-
"is",
|
|
9546
|
-
"are",
|
|
9547
|
-
"um",
|
|
9548
|
-
"uh"
|
|
9549
|
-
]);
|
|
9550
|
-
var TRAILING_PREPS = /* @__PURE__ */ new Set(["to", "for", "with", "about", "of", "at", "in", "on", "from", "by", "into", "under", "inside"]);
|
|
9551
|
-
var QUESTION_START = /^(?:what|who|whom|whose|where|when|why|how|which|is|are|was|were|do|does|did|can|could|will|would|should|have|has)\b/i;
|
|
9552
|
-
var PHRASAL_HOST = /* @__PURE__ */ new Set(["it", "this", "that", "them", "him", "her", "me", "us", "you", "up", "out", "off", "down", "back", "over", "away"]);
|
|
9553
|
-
function endsMidPhrase(text) {
|
|
9554
|
-
const t = text.split("\n[")[0].trim();
|
|
9555
|
-
if (!t || /[.?!…]["')\]]*$/.test(t)) return false;
|
|
9556
|
-
if (/\b[a-z][\w+.-]*:\/\/\S+$/i.test(t)) return false;
|
|
9557
|
-
if (/[,/\\:\-–—]$/.test(t)) return true;
|
|
9558
|
-
const [last, prev] = (t.toLowerCase().match(/[\p{L}']+/gu) ?? []).reverse();
|
|
9559
|
-
if (!last) return false;
|
|
9560
|
-
if (TRAILING_PREPS.has(last)) return !QUESTION_START.test(t) && !PHRASAL_HOST.has(prev);
|
|
9561
|
-
return MID_PHRASE_WORDS.has(last);
|
|
9562
|
-
}
|
|
9563
10166
|
function nameScore(want, have) {
|
|
9564
10167
|
const a = want.toLowerCase().replace(/[^\p{L}\p{N}]/gu, ""), b = have.toLowerCase().replace(/[^\p{L}\p{N}]/gu, "");
|
|
9565
10168
|
if (!a || !b) return 0;
|
|
@@ -9593,7 +10196,7 @@ function isOfferAcceptance(text) {
|
|
|
9593
10196
|
}
|
|
9594
10197
|
var isQuestion = (s) => /\?["'”’)\]]*$/.test(s.trim());
|
|
9595
10198
|
var OFFER_OVERRUN = 150;
|
|
9596
|
-
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. A short yes or agreement right after you asked or offered something answers THAT question: act on it now (dispatch the work it needs) \u2014 never re-ask it, rephrase it, or narrow it into a new question. When the user says you looked in the wrong place, or doubts a "not found", do not defend the search you did: dispatch `Act` with their hint (the place, the id, the tool they name). Only say you checked or searched something you actually checked with a tool this conversation \u2014 name exactly what you looked at.\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 it ENDS mid-sentence or on a hesitation ("and then we", "but the problem is", "so I was, um") \u2014 call `Hold` instead of answering. A hesitation INSIDE a finished message ("can you, um, open it?") does not make it incomplete, and an unclear or odd but finished message is not a fragment: answer it, or ask one short question \u2014 never Hold on it. 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 \u2014 to stop everything ("stop all", "cancel everything") pass id "all". When the user only ADDS to or CORRECTS a request whose task is still running (a detail, a path, a name, a spelling), call `Act` with `refines` set to that task id \u2014 it updates the running worker in place; never cancel and re-dispatch for a refinement.\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 (audio transcribed to text) and can carry transcription errors \u2014 odd words, cut-offs, homophones ("for you" vs "folder"), and especially MISHEARD NAMES: a surname, company, or project name often comes out as ordinary sound-alike words. Read for INTENT, not surface text, and do not silently rewrite what they said. If a message seems garbled, surprising, or only half-parses, or a word sounds like a name you already know (from memory or this conversation) but is spelled differently, treat it with suspicion \u2014 do NOT act on the odd word blindly or improvise content from it; briefly confirm what they meant ("did you mean\u2026?") and wait. A one-line confirm beats a confident wrong answer. NEVER save to memory a name, spelling, or term you only HEARD (it may be a transcription error) \u2014 when the user spells something aloud, read the spelling back and get a yes before storing it.';
|
|
10199
|
+
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. A short yes or agreement right after you asked or offered something answers THAT question: act on it now (dispatch the work it needs) \u2014 never re-ask it, rephrase it, or narrow it into a new question. When the user says you looked in the wrong place, or doubts a "not found", do not defend the search you did: dispatch `Act` with their hint (the place, the id, the tool they name). Only say you checked or searched something you actually checked with a tool this conversation \u2014 name exactly what you looked at.\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 it ENDS mid-sentence or on a hesitation ("and then we", "but the problem is", "so I was, um") \u2014 call `Hold` instead of answering. A hesitation INSIDE a finished message ("can you, um, open it?") does not make it incomplete, and an unclear or odd but finished message is not a fragment: answer it, or ask one short question \u2014 never Hold on it. 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 \u2014 to stop everything ("stop all", "cancel everything") pass id "all". When the user restates, continues, or rephrases a question a still-running task is already answering, it is the SAME question \u2014 do not answer it yourself (the task\'s answer is coming; two answers confuse): say briefly that you are on it. When the user only ADDS to or CORRECTS a request whose task is still running (a detail, a path, a name, a spelling), call `Act` with `refines` set to that task id \u2014 it updates the running worker in place; never cancel and re-dispatch for a refinement.\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 (audio transcribed to text) and can carry transcription errors \u2014 odd words, cut-offs, homophones ("for you" vs "folder"), and especially MISHEARD NAMES: a surname, company, or project name often comes out as ordinary sound-alike words. Read for INTENT, not surface text, and do not silently rewrite what they said. If a message seems garbled, surprising, or only half-parses, or a word sounds like a name you already know (from memory or this conversation) but is spelled differently, treat it with suspicion \u2014 do NOT act on the odd word blindly or improvise content from it; briefly confirm what they meant ("did you mean\u2026?") and wait. A one-line confirm beats a confident wrong answer. SPELLED-OUT WORDS: when the user spells something letter by letter (single letters joined by dashes or spaces, "dot" for "."), the spelling is EXACT and OVERRIDES the word you heard: assemble exactly those letters and use that string \u2014 never keep the sound-alike word, never blend the two, never add letters they did not say. A message noting `[spelled out letter by letter: "\u2026"]` has done the assembly for you. If you already used the heard word, say the corrected one back once.\nNEVER save to memory a name, spelling, or term you only HEARD (it may be a transcription error) \u2014 when the user spells something aloud, read the spelling back and get a yes before storing it.';
|
|
9597
10200
|
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.";
|
|
9598
10201
|
var THINK_DISABLED_GUIDANCE = "(Think tier is not available \u2014 use Act for all escalations.)";
|
|
9599
10202
|
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).`;
|
|
@@ -9760,7 +10363,7 @@ ${nowLine()}. Anchor every relative or time-sensitive reference \u2014 "today",
|
|
|
9760
10363
|
const m = this.reflexBuf.match(RESERVED_EVENT_MARKER) ?? this.reflexBuf.match(RESERVED_EVENT_OPENER);
|
|
9761
10364
|
if (m) {
|
|
9762
10365
|
this.fabricationCut = true;
|
|
9763
|
-
|
|
10366
|
+
log24.warn(`reflex fabricated a [task \u2026] event in its spoken stream \u2014 cutting it (kept ${m.index} chars)`);
|
|
9764
10367
|
this.reflexBuf = this.reflexBuf.slice(0, Math.max(m.index, this.reflexForwarded));
|
|
9765
10368
|
this.pumpReflex(true);
|
|
9766
10369
|
return;
|
|
@@ -9852,10 +10455,18 @@ ${nowLine()}. Anchor every relative or time-sensitive reference \u2014 "today",
|
|
|
9852
10455
|
for (const k of this.stepMarks) if (k > from && k <= upto) ends.push(k - from);
|
|
9853
10456
|
if (final || !ends.length && region.length >= 160) ends.push(region.length);
|
|
9854
10457
|
ends.sort((a, b) => a - b);
|
|
9855
|
-
let ok = 0, leak, repeat;
|
|
10458
|
+
let ok = 0, leak, repeat, stub;
|
|
9856
10459
|
for (const e of ends) {
|
|
9857
10460
|
if (e <= ok) continue;
|
|
9858
10461
|
const sent = region.slice(ok, e);
|
|
10462
|
+
if (isFalseStart(sent)) {
|
|
10463
|
+
const e2 = ends.find((x) => x > e);
|
|
10464
|
+
if (e2 === void 0 && !final) break;
|
|
10465
|
+
if (e2 !== void 0 && restarts(sent, region.slice(e, e2))) {
|
|
10466
|
+
stub = [ok, e];
|
|
10467
|
+
break;
|
|
10468
|
+
}
|
|
10469
|
+
}
|
|
9859
10470
|
if (sent.trim() && !/[\p{L}\p{N}]/u.test(sent) || isRepeatOf(sent, this.turnSpoken + region.slice(0, ok))) {
|
|
9860
10471
|
repeat = e;
|
|
9861
10472
|
break;
|
|
@@ -9870,6 +10481,16 @@ ${nowLine()}. Anchor every relative or time-sensitive reference \u2014 "today",
|
|
|
9870
10481
|
ok = e;
|
|
9871
10482
|
}
|
|
9872
10483
|
this.forwardReflex(from + ok);
|
|
10484
|
+
if (stub) {
|
|
10485
|
+
const [a, b] = [from + stub[0], from + stub[1]];
|
|
10486
|
+
this.repeatCuts.push(region.slice(stub[0], stub[1]).trim());
|
|
10487
|
+
this.notify("diag", "reflex_stub", { text: region.slice(stub[0], stub[1]).trim().slice(0, 40) });
|
|
10488
|
+
this.reflexBuf = this.reflexBuf.slice(0, a) + this.reflexBuf.slice(b);
|
|
10489
|
+
this.stepMarks = this.stepMarks.map((k) => k >= b ? k - (b - a) : k > a ? a : k);
|
|
10490
|
+
if (this.dispatchAt >= b) this.dispatchAt -= b - a;
|
|
10491
|
+
else if (this.dispatchAt > a) this.dispatchAt = a;
|
|
10492
|
+
continue;
|
|
10493
|
+
}
|
|
9873
10494
|
if (repeat !== void 0) {
|
|
9874
10495
|
const cut = region.slice(ok, repeat).trim();
|
|
9875
10496
|
if (/[\p{L}\p{N}]/u.test(cut)) {
|
|
@@ -9884,7 +10505,7 @@ ${nowLine()}. Anchor every relative or time-sensitive reference \u2014 "today",
|
|
|
9884
10505
|
this.leakHit = true;
|
|
9885
10506
|
this.leakCuts.push(leak.trim().slice(0, 40));
|
|
9886
10507
|
const why = this.cutKind === "claim" ? "stated a result no task has given yet" : this.cutKind === "restart" ? "started a second, restated reply" : "spoke its own analysis";
|
|
9887
|
-
|
|
10508
|
+
log24.warn(`reflex ${why} ("${leak.trim().slice(0, 40)}\u2026") \u2014 cut before TTS`);
|
|
9888
10509
|
this.notify("diag", this.cutKind === "claim" ? "result_claim" : this.cutKind === "restart" ? "reply_restart" : "analysis_leak", { text: leak.trim().slice(0, 80), at: this.turnSpoken.trim() ? "mid_reply" : "before_speech" });
|
|
9889
10510
|
const next = this.stepMarks.find((k) => k > at);
|
|
9890
10511
|
if (next === void 0) {
|
|
@@ -9925,7 +10546,7 @@ ${nowLine()}. Anchor every relative or time-sensitive reference \u2014 "today",
|
|
|
9925
10546
|
this.notify("diag", "leak_judge", { text: s.slice(0, 80), p: Math.round(p * 100) / 100, ms: Math.round(performance.now() - t0) });
|
|
9926
10547
|
return p >= LEAK_JUDGE_THRESHOLD;
|
|
9927
10548
|
}, (e) => {
|
|
9928
|
-
|
|
10549
|
+
log24.warn(`leak judge failed \u2014 speaking the sentence: ${e.message}`);
|
|
9929
10550
|
return false;
|
|
9930
10551
|
}).then((leak) => {
|
|
9931
10552
|
clearTimeout(timer);
|
|
@@ -9997,7 +10618,7 @@ ${nowLine()}. Anchor every relative or time-sensitive reference \u2014 "today",
|
|
|
9997
10618
|
let i = t.length - 1;
|
|
9998
10619
|
while (i >= this.turnBase && !(t[i].role === "assistant" && typeof t[i].content === "string" && t[i].content.includes(key))) i--;
|
|
9999
10620
|
if (i < this.turnBase) {
|
|
10000
|
-
|
|
10621
|
+
log24.warn("analysis leak cut, but its text was not found in the transcript to scrub");
|
|
10001
10622
|
continue;
|
|
10002
10623
|
}
|
|
10003
10624
|
const m = t[i];
|
|
@@ -10011,10 +10632,12 @@ ${nowLine()}. Anchor every relative or time-sensitive reference \u2014 "today",
|
|
|
10011
10632
|
* turn. Strips the text of assistant messages after the Hold call (tool calls stay — they happened); a message left
|
|
10012
10633
|
* empty is removed. */
|
|
10013
10634
|
scrubHeldText() {
|
|
10014
|
-
|
|
10635
|
+
const ackedDispatch = !this.heldThisTurn && this.turnDispatched && this.spokeBeforeDispatch;
|
|
10636
|
+
if (!this.heldThisTurn && !ackedDispatch) return;
|
|
10637
|
+
const anchor = (n) => ackedDispatch ? n === "Act" || n === "Think" : n === "Hold";
|
|
10015
10638
|
const t = this.voice.transcript;
|
|
10016
10639
|
let i = this.turnBase;
|
|
10017
|
-
while (i < t.length && !t[i].tool_calls?.some((c) => c.function.name
|
|
10640
|
+
while (i < t.length && !t[i].tool_calls?.some((c) => anchor(c.function.name))) i++;
|
|
10018
10641
|
for (let j = t.length - 1; j > i; j--) {
|
|
10019
10642
|
const m = t[j];
|
|
10020
10643
|
if (m.role !== "assistant" || !contentText(m.content).trim()) continue;
|
|
@@ -10052,7 +10675,7 @@ ${nowLine()}. Anchor every relative or time-sensitive reference \u2014 "today",
|
|
|
10052
10675
|
const res = await this.voice.send(content);
|
|
10053
10676
|
const fb = this.options.reflexFallbackModel, model = this.voice.options.model;
|
|
10054
10677
|
if (res.finishReason !== "error" || !rateLimited(res.error) || !fb || fb === model || fb.includes("/") && model.includes("/") && fb.split("/")[0] !== model.split("/")[0] || this.turnDispatched || this.turnSpoken.trim()) return res;
|
|
10055
|
-
|
|
10678
|
+
log24.warn(`reflex ${model} rate-limited \u2014 re-running the turn on ${fb}`);
|
|
10056
10679
|
this.notify("diag", "reflex_fallback", { from: model, to: fb });
|
|
10057
10680
|
this.voice.transcript.length = base;
|
|
10058
10681
|
this.voice.options.model = fb;
|
|
@@ -10085,7 +10708,7 @@ ${nowLine()}. Anchor every relative or time-sensitive reference \u2014 "today",
|
|
|
10085
10708
|
try {
|
|
10086
10709
|
await this.voice.send(reminder);
|
|
10087
10710
|
} catch (e) {
|
|
10088
|
-
|
|
10711
|
+
log24.warn(`follow-through nudge failed: ${e instanceof Error ? e.message : e}`);
|
|
10089
10712
|
} finally {
|
|
10090
10713
|
const i = this.voice.transcript.findIndex((m, k) => k >= base && m.role === "user" && m.content === reminder);
|
|
10091
10714
|
if (i >= 0) this.voice.transcript.splice(i, 1);
|
|
@@ -10146,7 +10769,7 @@ ${nowLine()}. Anchor every relative or time-sensitive reference \u2014 "today",
|
|
|
10146
10769
|
return {
|
|
10147
10770
|
preToolUse: (call) => {
|
|
10148
10771
|
if (this.spec?.state === "pending" && !_DuplexAgent.SPEC_SAFE_TOOLS.has(call.name)) {
|
|
10149
|
-
|
|
10772
|
+
log24.verbose(`speculation aborted: reflex called ${call.name} on unconfirmed input`);
|
|
10150
10773
|
this.abortSpeculation();
|
|
10151
10774
|
return { block: true, reason: "Speculative turn aborted." };
|
|
10152
10775
|
}
|
|
@@ -10189,11 +10812,19 @@ ${nowLine()}. Anchor every relative or time-sensitive reference \u2014 "today",
|
|
|
10189
10812
|
this.holdTimer = null;
|
|
10190
10813
|
this.holdResumed = true;
|
|
10191
10814
|
this.notify("diag", "hold_expired", { ms });
|
|
10192
|
-
this.queueRevoice(
|
|
10815
|
+
this.queueRevoice(this.holdExpiredNote());
|
|
10193
10816
|
}, ms);
|
|
10194
10817
|
this.holdTimer.unref?.();
|
|
10195
10818
|
}
|
|
10196
10819
|
static HOLD_EXPIRED = "[the user stopped there and did not go on \u2014 respond to what they said now; if it is unclear, ask one short question. Do not Hold.]";
|
|
10820
|
+
/** HOLD_EXPIRED + the tasks still running: live (20261014-095941) the held line was the user re-asking the question t2
|
|
10821
|
+
* was already answering; "respond now" made the reflex answer it from its own knowledge — two answers. */
|
|
10822
|
+
holdExpiredNote() {
|
|
10823
|
+
const running = [...this.tasks.values()].filter((t) => t.status === "running");
|
|
10824
|
+
if (!running.length) return _DuplexAgent.HOLD_EXPIRED;
|
|
10825
|
+
const list = running.map((t) => `${t.id} (${t.label})`).join(", ");
|
|
10826
|
+
return _DuplexAgent.HOLD_EXPIRED.slice(0, -1) + ` Still running: ${list}. If what they said is about a running task, do NOT answer it yourself \u2014 say in one short line that you are on it, or call Act with \`refines\` if they added a detail.]`;
|
|
10827
|
+
}
|
|
10197
10828
|
/** True when the just-finished turn voiced NOTHING — dead air to repair. Two ways this happens:
|
|
10198
10829
|
* (a) a dispatch with no spoken ack, and (b) an INLINE turn whose `final` channel came back empty —
|
|
10199
10830
|
* gpt-oss harmony sometimes puts the whole reply in `analysis` (→ thinking_delta, suppressed in
|
|
@@ -10220,7 +10851,7 @@ ${nowLine()}. Anchor every relative or time-sensitive reference \u2014 "today",
|
|
|
10220
10851
|
try {
|
|
10221
10852
|
await this.voice.send(reminder);
|
|
10222
10853
|
} catch (e) {
|
|
10223
|
-
|
|
10854
|
+
log24.warn(`ack nudge failed: ${e instanceof Error ? e.message : e}`);
|
|
10224
10855
|
} finally {
|
|
10225
10856
|
this.nudging = false;
|
|
10226
10857
|
const i = this.voice.transcript.findIndex((m, k) => k >= base && m.role === "user" && m.content === reminder);
|
|
@@ -10275,7 +10906,7 @@ ${nowLine()}. Anchor every relative or time-sensitive reference \u2014 "today",
|
|
|
10275
10906
|
const accepted = this.offerOpen && this.parkedRedeliver.length > 0 && this.parkedRedeliver.every((p) => p.task === this.offer.task) && (isOfferAcceptance(content) || isTrivialBarge(content));
|
|
10276
10907
|
const judge = accepted ? void 0 : this.options.bargeIntent;
|
|
10277
10908
|
return (accepted ? Promise.resolve("resume") : judge ? judge(content).catch((e) => {
|
|
10278
|
-
|
|
10909
|
+
log24.warn(`bargeIntent judge failed \u2014 using the allowlist: ${e.message}`);
|
|
10279
10910
|
return fallback();
|
|
10280
10911
|
}) : Promise.resolve(fallback())).then((intent) => {
|
|
10281
10912
|
this.notify("diag", "barge_intent", { intent, text: content.slice(0, 80) });
|
|
@@ -10418,6 +11049,7 @@ ${nowLine()}. Anchor every relative or time-sensitive reference \u2014 "today",
|
|
|
10418
11049
|
this.voice.options.signal = signal;
|
|
10419
11050
|
this.turnSignal = { own: signal, ext };
|
|
10420
11051
|
}
|
|
11052
|
+
if (typeof content === "string") content += spellingNote(content);
|
|
10421
11053
|
let res;
|
|
10422
11054
|
try {
|
|
10423
11055
|
res = await this.reflexSend(content);
|
|
@@ -10471,7 +11103,7 @@ ${nowLine()}. Anchor every relative or time-sensitive reference \u2014 "today",
|
|
|
10471
11103
|
try {
|
|
10472
11104
|
res = await this.voice.send(spec.text);
|
|
10473
11105
|
} catch (e) {
|
|
10474
|
-
|
|
11106
|
+
log24.warn(`speculative turn failed: ${e instanceof Error ? e.message : e}`);
|
|
10475
11107
|
} finally {
|
|
10476
11108
|
this.voice.options.signal = prevSignal;
|
|
10477
11109
|
}
|
|
@@ -10485,7 +11117,7 @@ ${nowLine()}. Anchor every relative or time-sensitive reference \u2014 "today",
|
|
|
10485
11117
|
if (d === "abort") {
|
|
10486
11118
|
if (this.voice.transcript.length > base) this.voice.transcript.length = base;
|
|
10487
11119
|
this.speculativeAbortedCalls++;
|
|
10488
|
-
|
|
11120
|
+
log24.verbose(`speculation aborted (${this.speculativeAbortedCalls} total): "${spec.text.slice(0, 50)}"`);
|
|
10489
11121
|
if (this.spec === spec) this.spec = void 0;
|
|
10490
11122
|
return res ?? empty;
|
|
10491
11123
|
}
|
|
@@ -10749,7 +11381,7 @@ Update: ${brief}`;
|
|
|
10749
11381
|
}
|
|
10750
11382
|
buildBrief(brief, tier = "act", deliver = true) {
|
|
10751
11383
|
const recent = this.recentExcerpt();
|
|
10752
|
-
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." : "";
|
|
11384
|
+
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. A command exiting 0 or echoing its input means it was ACCEPTED, not that it worked: before you say a web address was opened or reached, confirm it exists (its host resolves in DNS and answers an HTTP request); if it does not, report that it does not exist instead of success, and name the address you tried." : "";
|
|
10753
11385
|
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. Never read a file path, URL, command or code aloud, nor spell one out ("slash \u2026 slash \u2026") \u2014 say it the way a person would, by the name of the folder, project, file or site; the exact text stays in your written prose. ' + (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." : "") : "";
|
|
10754
11386
|
return `${nowLine()}.
|
|
10755
11387
|
|
|
@@ -10868,7 +11500,7 @@ Another agent just implemented the above. Independently check the CURRENT state
|
|
|
10868
11500
|
this.notify("task_verify", `task ${id}: verifying`, { id });
|
|
10869
11501
|
const cres = await new Agent(checkerOpts).run(checkBrief);
|
|
10870
11502
|
if (cres.finishReason !== "stop") {
|
|
10871
|
-
|
|
11503
|
+
log24.warn(`task ${id}: verify inconclusive (${cres.finishReason})`);
|
|
10872
11504
|
this.notify("task_verify", `task ${id}: verify inconclusive (${cres.finishReason})`, { id, finishReason: cres.finishReason });
|
|
10873
11505
|
}
|
|
10874
11506
|
const sum = (a = 0, b = 0) => a + b;
|
|
@@ -11004,7 +11636,7 @@ Another agent just implemented the above. Independently check the CURRENT state
|
|
|
11004
11636
|
rec.status = "done";
|
|
11005
11637
|
rec.result = res.text;
|
|
11006
11638
|
const incomplete = res.finishReason !== "stop" && res.finishReason !== "empty";
|
|
11007
|
-
|
|
11639
|
+
log24.verbose(`task ${id} done (${res.steps} steps${incomplete ? `, INCOMPLETE: ${res.finishReason}` : ""})`);
|
|
11008
11640
|
this.notify("task_done", `task ${id} (${rec.label}) completed`, {
|
|
11009
11641
|
id,
|
|
11010
11642
|
text: res.text,
|
|
@@ -11038,14 +11670,14 @@ Another agent just implemented the above. Independently check the CURRENT state
|
|
|
11038
11670
|
foldIfSuperseded(rec, note) {
|
|
11039
11671
|
if (rec.deliveryParked !== "moved_on") return false;
|
|
11040
11672
|
if (note.trim()) this.voice.transcript.push({ role: "assistant", content: note });
|
|
11041
|
-
|
|
11673
|
+
log24.verbose(`task ${rec.id} settled but superseded (${rec.status}) \u2014 folded into transcript, not voiced`);
|
|
11042
11674
|
return true;
|
|
11043
11675
|
}
|
|
11044
11676
|
failTask(rec, msg) {
|
|
11045
11677
|
this.dropAsk(rec.id);
|
|
11046
11678
|
rec.status = "error";
|
|
11047
11679
|
rec.result = msg;
|
|
11048
|
-
|
|
11680
|
+
log24.warn(`task ${rec.id} failed: ${msg}`);
|
|
11049
11681
|
this.notify("task_error", `task ${rec.id} (${rec.label}) failed: ${msg}`, { id: rec.id });
|
|
11050
11682
|
if (this.foldIfSuperseded(rec, `The "${rec.label}" task failed: ${msg}`)) return;
|
|
11051
11683
|
this.dropParked(rec);
|
|
@@ -11307,11 +11939,17 @@ Another agent just implemented the above. Independently check the CURRENT state
|
|
|
11307
11939
|
filler: { type: "string", description: 'optional short filler to speak ("mhm", "go on", "mm-hm")' }
|
|
11308
11940
|
}
|
|
11309
11941
|
},
|
|
11310
|
-
|
|
11311
|
-
|
|
11942
|
+
// A held turn ENDS here (ctx.endTurn): live (20261014-095941) the reflex re-called Hold after "Do not respond
|
|
11943
|
+
// further" until the repeat kill-switch fired (3 steps, 21.6k tokens) — a model step it never needed.
|
|
11944
|
+
run: async ({ filler }, ctx) => {
|
|
11945
|
+
if (this.heldThisTurn) {
|
|
11946
|
+
ctx.endTurn = true;
|
|
11947
|
+
return "Already holding \u2014 stay silent.";
|
|
11948
|
+
}
|
|
11312
11949
|
if (this.holdResumed) return "Not now \u2014 the user already stopped after those words. Answer them now in one or two short sentences, or ask one short question.";
|
|
11313
11950
|
this.heldThisTurn = true;
|
|
11314
11951
|
this.notify("hold_filler", filler ? String(filler) : "");
|
|
11952
|
+
ctx.endTurn = true;
|
|
11315
11953
|
return "Holding \u2014 listening for the rest of the user's thought. Do not respond further this turn.";
|
|
11316
11954
|
}
|
|
11317
11955
|
};
|
|
@@ -11343,7 +11981,7 @@ Another agent just implemented the above. Independently check the CURRENT state
|
|
|
11343
11981
|
init_llm();
|
|
11344
11982
|
init_logging();
|
|
11345
11983
|
init_models();
|
|
11346
|
-
var
|
|
11984
|
+
var log25 = forComponent("ModelRouter");
|
|
11347
11985
|
var ModelRouterOptions = class {
|
|
11348
11986
|
classifier = jevClassifier();
|
|
11349
11987
|
/** Capability tiers → model id (any provider). basic/standard run the main session; premium is only ever an offload. */
|
|
@@ -11427,10 +12065,10 @@ var ModelRouter = class {
|
|
|
11427
12065
|
if (tier === "premium") d.offload = this.state.offload && (c.sameThread ?? 0) >= o.sameThreadAt ? "continue" : "fresh";
|
|
11428
12066
|
} catch (e) {
|
|
11429
12067
|
const msg = e instanceof Error ? e.message : String(e);
|
|
11430
|
-
|
|
12068
|
+
log25.warn(`classifier failed, falling back to standard: ${msg}`);
|
|
11431
12069
|
d = { tier: "standard", model: o.models.standard, probs: {}, fallback: msg, classifyMs: Math.round(performance.now() - t0) };
|
|
11432
12070
|
}
|
|
11433
|
-
|
|
12071
|
+
log25.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`);
|
|
11434
12072
|
return d;
|
|
11435
12073
|
}
|
|
11436
12074
|
/** Run one user turn on `agent` with routing. Returns a RunResult carrying `costUsd` + `routing`. */
|
|
@@ -11482,7 +12120,7 @@ ${text}`;
|
|
|
11482
12120
|
const answer = res.text || `(premium sub-agent finished with no reply; finishReason=${res.finishReason})`;
|
|
11483
12121
|
agent.transcript.push({ role: "user", content }, { role: "assistant", content: answer });
|
|
11484
12122
|
this.state.offload = { messages: child.transcript, mainLen: agent.transcript.length, question: text };
|
|
11485
|
-
|
|
12123
|
+
log25.info(`offload ${cont ? "continue" : "fresh"} done \xB7 ${res.finishReason} \xB7 ${res.steps} steps`);
|
|
11486
12124
|
const costUsd = o.costOf(main, briefUsage) + o.costOf(o.models.premium, res.usage);
|
|
11487
12125
|
return { ...res, text: answer, messages: agent.transcript, usage: addUsage(briefUsage, res.usage), costUsd, routing: d };
|
|
11488
12126
|
}
|
|
@@ -11498,7 +12136,7 @@ The request:
|
|
|
11498
12136
|
${text}`);
|
|
11499
12137
|
return { text: r.text.trim() || text, usage: r.usage };
|
|
11500
12138
|
} catch (e) {
|
|
11501
|
-
|
|
12139
|
+
log25.warn(`brief failed, sending the raw request: ${e instanceof Error ? e.message : String(e)}`);
|
|
11502
12140
|
return { text };
|
|
11503
12141
|
} finally {
|
|
11504
12142
|
agent.transcript = snap;
|
|
@@ -11543,7 +12181,7 @@ async function jevAvailable(opts = {}) {
|
|
|
11543
12181
|
const { env } = await loadJev(opts);
|
|
11544
12182
|
return JEV_KEYS.some((k) => env[k]?.trim());
|
|
11545
12183
|
} catch (e) {
|
|
11546
|
-
|
|
12184
|
+
log25.debug(`jev unavailable: ${e instanceof Error ? e.message : String(e)}`);
|
|
11547
12185
|
return false;
|
|
11548
12186
|
}
|
|
11549
12187
|
}
|
|
@@ -11821,6 +12459,979 @@ var RecordingLifecycle = class {
|
|
|
11821
12459
|
|
|
11822
12460
|
// src/index.ts
|
|
11823
12461
|
init_logging();
|
|
12462
|
+
|
|
12463
|
+
// src/voice/call.ts
|
|
12464
|
+
init_logging();
|
|
12465
|
+
init_models();
|
|
12466
|
+
|
|
12467
|
+
// src/voice/progress.ts
|
|
12468
|
+
init_logging();
|
|
12469
|
+
var log26 = forComponent("Progress");
|
|
12470
|
+
var HE_RESULT_CLAIM = /(?:^|\s)(?:מצאתי|סיימתי|בוצע|הנה|אין)(?=\s|$|[.,!…])/u;
|
|
12471
|
+
var TOPICS = [
|
|
12472
|
+
[/kanban|board|card|\bkb\b|list_cards/, "board"],
|
|
12473
|
+
[/mail|gmail|inbox/, "mail"],
|
|
12474
|
+
[/calendar|event|schedule/, "calendar"],
|
|
12475
|
+
[/memory|recall|remember/, "memory"],
|
|
12476
|
+
[/web_?search|websearch|fetch|browse|url|page|tab|browser/, "web"],
|
|
12477
|
+
[/grep|glob|search|find|lookup|quicklook/, "search"],
|
|
12478
|
+
[/edit|write|patch|create|update|save|delete|move/, "write"],
|
|
12479
|
+
[/shell|bash|exec|run|command|terminal/, "run"],
|
|
12480
|
+
[/read|open|view|cat|look|get|list|ls/, "read"]
|
|
12481
|
+
];
|
|
12482
|
+
var PROGRESS_TEMPLATES = {
|
|
12483
|
+
en: {
|
|
12484
|
+
board: ["checking the board\u2026", "looking at the cards\u2026"],
|
|
12485
|
+
mail: ["checking the mail\u2026", "going through the inbox\u2026"],
|
|
12486
|
+
calendar: ["checking the calendar\u2026", "looking at the schedule\u2026"],
|
|
12487
|
+
memory: ["checking my notes\u2026", "looking back\u2026"],
|
|
12488
|
+
web: ["reading the page\u2026", "looking it up online\u2026"],
|
|
12489
|
+
search: ["searching\u2026", "still looking\u2026"],
|
|
12490
|
+
write: ["making the change\u2026", "writing it down\u2026"],
|
|
12491
|
+
run: ["running it\u2026", "still running\u2026"],
|
|
12492
|
+
read: ["reading it\u2026", "going through it\u2026"]
|
|
12493
|
+
},
|
|
12494
|
+
he: {
|
|
12495
|
+
// plural present: gender-neutral for a voice of either gender
|
|
12496
|
+
board: ["\u05D1\u05D5\u05D3\u05E7\u05D9\u05DD \u05D1\u05DC\u05D5\u05D7\u2026", "\u05DE\u05E1\u05EA\u05DB\u05DC\u05D9\u05DD \u05E2\u05DC \u05D4\u05DB\u05E8\u05D8\u05D9\u05E1\u05D9\u05DD\u2026"],
|
|
12497
|
+
mail: ["\u05D1\u05D5\u05D3\u05E7\u05D9\u05DD \u05D0\u05EA \u05D4\u05DE\u05D9\u05D9\u05DC\u2026", "\u05E2\u05D5\u05D1\u05E8\u05D9\u05DD \u05E2\u05DC \u05D4\u05D3\u05D5\u05D0\u05E8\u2026"],
|
|
12498
|
+
calendar: ["\u05D1\u05D5\u05D3\u05E7\u05D9\u05DD \u05D1\u05D9\u05D5\u05DE\u05DF\u2026", "\u05DE\u05E1\u05EA\u05DB\u05DC\u05D9\u05DD \u05D1\u05DC\u05D5\u05F4\u05D6\u2026"],
|
|
12499
|
+
memory: ["\u05D1\u05D5\u05D3\u05E7\u05D9\u05DD \u05D1\u05E8\u05E9\u05D9\u05DE\u05D5\u05EA\u2026", "\u05DE\u05D7\u05E4\u05E9\u05D9\u05DD \u05D1\u05D6\u05D9\u05DB\u05E8\u05D5\u05DF\u2026"],
|
|
12500
|
+
web: ["\u05E7\u05D5\u05E8\u05D0\u05D9\u05DD \u05D0\u05EA \u05D4\u05D3\u05E3\u2026", "\u05DE\u05D7\u05E4\u05E9\u05D9\u05DD \u05D1\u05E8\u05E9\u05EA\u2026"],
|
|
12501
|
+
search: ["\u05DE\u05D7\u05E4\u05E9\u05D9\u05DD\u2026", "\u05E2\u05D3\u05D9\u05D9\u05DF \u05DE\u05D7\u05E4\u05E9\u05D9\u05DD\u2026"],
|
|
12502
|
+
write: ["\u05DE\u05D1\u05E6\u05E2\u05D9\u05DD \u05D0\u05EA \u05D4\u05E9\u05D9\u05E0\u05D5\u05D9\u2026", "\u05DB\u05D5\u05EA\u05D1\u05D9\u05DD \u05D0\u05EA \u05D6\u05D4\u2026"],
|
|
12503
|
+
run: ["\u05DE\u05E8\u05D9\u05E6\u05D9\u05DD\u2026", "\u05E2\u05D3\u05D9\u05D9\u05DF \u05E8\u05E5\u2026"],
|
|
12504
|
+
read: ["\u05E7\u05D5\u05E8\u05D0\u05D9\u05DD\u2026", "\u05E2\u05D5\u05D1\u05E8\u05D9\u05DD \u05E2\u05DC \u05D6\u05D4\u2026"]
|
|
12505
|
+
}
|
|
12506
|
+
};
|
|
12507
|
+
var ProgressOptions = class {
|
|
12508
|
+
templates = PROGRESS_TEMPLATES;
|
|
12509
|
+
/** Fast-model fallback for a tool with no template (fastProgressGenerator). Absent → silence. */
|
|
12510
|
+
generate;
|
|
12511
|
+
generateBudgetMs = 800;
|
|
12512
|
+
/** Speak only when nothing has been audible for this long. */
|
|
12513
|
+
silenceMs = 3e3;
|
|
12514
|
+
/** At most one progress line per this long. */
|
|
12515
|
+
gapMs = 4e3;
|
|
12516
|
+
now = () => performance.now();
|
|
12517
|
+
};
|
|
12518
|
+
function argsHint(args) {
|
|
12519
|
+
if (!args || typeof args !== "object") return typeof args === "string" ? args.slice(0, 40) : "";
|
|
12520
|
+
const s = Object.values(args).find((v) => typeof v === "string" && v.trim());
|
|
12521
|
+
return typeof s === "string" ? s.trim().replace(/\s+/g, " ").slice(0, 40) : "";
|
|
12522
|
+
}
|
|
12523
|
+
var Progress = class {
|
|
12524
|
+
constructor(options) {
|
|
12525
|
+
this.options = options;
|
|
12526
|
+
this.options = { ...new ProgressOptions(), ...options };
|
|
12527
|
+
}
|
|
12528
|
+
options;
|
|
12529
|
+
said = /* @__PURE__ */ new Set();
|
|
12530
|
+
lastAt = -Infinity;
|
|
12531
|
+
streamed = false;
|
|
12532
|
+
get o() {
|
|
12533
|
+
return this.options;
|
|
12534
|
+
}
|
|
12535
|
+
/** A new MAIN turn started. */
|
|
12536
|
+
turn() {
|
|
12537
|
+
this.streamed = false;
|
|
12538
|
+
}
|
|
12539
|
+
/** MAIN's text streamed this turn: no more progress lines for it. */
|
|
12540
|
+
text() {
|
|
12541
|
+
this.streamed = true;
|
|
12542
|
+
}
|
|
12543
|
+
/** May a line play now? (the caller passes when the user last heard anything) */
|
|
12544
|
+
due(lastAudibleAt) {
|
|
12545
|
+
const now3 = this.o.now();
|
|
12546
|
+
return !this.streamed && now3 - lastAudibleAt >= this.o.silenceMs && now3 - this.lastAt >= this.o.gapMs;
|
|
12547
|
+
}
|
|
12548
|
+
/** Mark `line` spoken. */
|
|
12549
|
+
spoke(line) {
|
|
12550
|
+
this.said.add(norm3(line));
|
|
12551
|
+
this.lastAt = this.o.now();
|
|
12552
|
+
}
|
|
12553
|
+
/** The line for this tool call in `lang`, or '' (no template and no/late/invalid generation, a repeat, a result claim). */
|
|
12554
|
+
async line(tool, args, lang) {
|
|
12555
|
+
if (this.streamed) return "";
|
|
12556
|
+
const name = tool.toLowerCase();
|
|
12557
|
+
const topic = TOPICS.find(([re]) => re.test(name))?.[1];
|
|
12558
|
+
const pool = topic ? this.o.templates[lang]?.[topic] ?? [] : [];
|
|
12559
|
+
const fresh = pool.find((l2) => !this.said.has(norm3(l2)));
|
|
12560
|
+
if (fresh) return fresh;
|
|
12561
|
+
if (!this.o.generate) return "";
|
|
12562
|
+
const ac = new AbortController();
|
|
12563
|
+
let t;
|
|
12564
|
+
const late = new Promise((r) => {
|
|
12565
|
+
t = setTimeout(() => {
|
|
12566
|
+
ac.abort();
|
|
12567
|
+
r(null);
|
|
12568
|
+
}, this.o.generateBudgetMs);
|
|
12569
|
+
});
|
|
12570
|
+
const raw = await Promise.race([this.o.generate(tool, argsHint(args), lang, ac.signal).catch((e) => {
|
|
12571
|
+
log26.warn(`progress line failed: ${e?.message ?? e}`);
|
|
12572
|
+
return null;
|
|
12573
|
+
}), late]).finally(() => clearTimeout(t));
|
|
12574
|
+
const l = cleanLine(raw ?? "", lang, 5);
|
|
12575
|
+
if (!l || RESULT_CLAIM_RE.test(l) || HE_RESULT_CLAIM.test(l) || this.said.has(norm3(l)) || this.streamed) return "";
|
|
12576
|
+
return l;
|
|
12577
|
+
}
|
|
12578
|
+
};
|
|
12579
|
+
function fastProgressGenerator(ai, model) {
|
|
12580
|
+
return async (tool, hint, lang, signal) => {
|
|
12581
|
+
const prompt = `An assistant on a live voice call is running the tool "${tool}"${hint ? ` (about: "${hint}")` : ""}. Write ONE spoken line of 2-5 words, in ${languageName(lang)}, saying what it is doing right now (like "checking the board\u2026"). Never state a result or a finding. Output only the line.`;
|
|
12582
|
+
const reasoning = /gpt-oss/.test(model) ? { providerOptions: { reasoning_effort: "low" } } : {};
|
|
12583
|
+
const r = await ai.chat({ model, messages: [{ role: "user", content: prompt }], stream: false, signal, maxTokens: 150, ...reasoning });
|
|
12584
|
+
return r.content ?? "";
|
|
12585
|
+
};
|
|
12586
|
+
}
|
|
12587
|
+
|
|
12588
|
+
// src/voice/secondChannel.ts
|
|
12589
|
+
init_logging();
|
|
12590
|
+
|
|
12591
|
+
// src/voice/backchannelPlan.ts
|
|
12592
|
+
init_logging();
|
|
12593
|
+
var log27 = forComponent("BackchannelPlan");
|
|
12594
|
+
var silentTurn = (p) => p.density === "none" || p.turn === "short_command" || p.turn === "question";
|
|
12595
|
+
var cueWords = (s) => s.toLowerCase().replace(/[^\p{L}\p{N}'\s]+/gu, " ").split(/\s+/).filter(Boolean);
|
|
12596
|
+
function matchCue(plan, heard, from = 0) {
|
|
12597
|
+
if (!plan || silentTurn(plan)) return null;
|
|
12598
|
+
const w = cueWords(heard);
|
|
12599
|
+
for (let i = Math.max(0, from); i < w.length; i++) {
|
|
12600
|
+
for (const c of plan.cues) {
|
|
12601
|
+
for (const p of c.when) {
|
|
12602
|
+
const pw = p.split(" ");
|
|
12603
|
+
if (i + pw.length <= w.length && pw.every((x, j) => w[i + j] === x)) return c;
|
|
12604
|
+
}
|
|
12605
|
+
}
|
|
12606
|
+
}
|
|
12607
|
+
return null;
|
|
12608
|
+
}
|
|
12609
|
+
|
|
12610
|
+
// src/voice/secondChannel.ts
|
|
12611
|
+
var log28 = forComponent("SecondChannel");
|
|
12612
|
+
var CHANNEL_MODES = {
|
|
12613
|
+
turn: { kinds: ["bridge"], bridgeDelayMs: 1500 },
|
|
12614
|
+
normal: {},
|
|
12615
|
+
chatty: {
|
|
12616
|
+
anticipate: true,
|
|
12617
|
+
cueOnly: false,
|
|
12618
|
+
generateFor: ["backchannel", "bridge"],
|
|
12619
|
+
reactionGapMs: 6e3,
|
|
12620
|
+
generateBudgetMs: 400,
|
|
12621
|
+
// A breath must outlast the word cadence: at ~2.5 words/s interims arrive every ~400ms, so a 250ms "breath" fired after
|
|
12622
|
+
// every word and only the 1.2s gaps held it (~25 lines per 30s, live probe). Lines ≥2.5s apart (light after light 3s), ≤6 in any 30s.
|
|
12623
|
+
backchannelMinWords: 5,
|
|
12624
|
+
backchannelGapMs: 3e3,
|
|
12625
|
+
minGapMs: 2500,
|
|
12626
|
+
backchannelPauseMs: 500,
|
|
12627
|
+
maxPerWindow: 6
|
|
12628
|
+
}
|
|
12629
|
+
};
|
|
12630
|
+
var CHANNEL_MODE_NAMES = Object.keys(CHANNEL_MODES);
|
|
12631
|
+
var SecondChannelOptions = class {
|
|
12632
|
+
/** Preset (CHANNEL_MODES) under the explicit options. */
|
|
12633
|
+
mode = "normal";
|
|
12634
|
+
/** Line kinds this channel may speak. */
|
|
12635
|
+
kinds = ["backchannel", "filler", "bridge"];
|
|
12636
|
+
/** A generated (content) backchannel needs this long since the last one; in between, canned light cues. 0 = always. */
|
|
12637
|
+
reactionGapMs = 0;
|
|
12638
|
+
/** Refiner (Jev/fast LLM): the choice over CHANNEL_ACTIONS, or null on failure. Absent → the rule alone decides.
|
|
12639
|
+
* `state.rule` is the kind the rule proposed (fastChannelDecider answers yes/no on it). `signal` aborts a late call. */
|
|
12640
|
+
decide;
|
|
12641
|
+
/** Fast-model line writer (see fastLineGenerator). Absent or late → the canned bank. */
|
|
12642
|
+
generate;
|
|
12643
|
+
/** Per-language banks (ChannelBanks). Absent → the hardcoded English bank, and silence in any other language. */
|
|
12644
|
+
banks;
|
|
12645
|
+
/** The host's language (BCP-47; pinned — detection never overrides it). Absent → unknown until the user's words
|
|
12646
|
+
* tell it confidently; the channel is silent until then (never English by default). */
|
|
12647
|
+
lang;
|
|
12648
|
+
/** Kinds worth generating (content-conditioned); the rest are canned sounds. */
|
|
12649
|
+
generateFor = ["bridge"];
|
|
12650
|
+
// a backchannel is timing, not content: canned at 0ms (a generated one arrived after the micro-pause)
|
|
12651
|
+
/** Host TTS on the second channel; resolves when the line finished playing. `false` = the host could not play it. */
|
|
12652
|
+
speak = () => {
|
|
12653
|
+
};
|
|
12654
|
+
/** Host UI (caption / muted line) — never history. */
|
|
12655
|
+
show = () => {
|
|
12656
|
+
};
|
|
12657
|
+
/** Stop the in-flight line's audio now (barge-in only). */
|
|
12658
|
+
cancel = () => {
|
|
12659
|
+
};
|
|
12660
|
+
onEvent = () => {
|
|
12661
|
+
};
|
|
12662
|
+
/** Echo cancellation on: backchannels may play while the user speaks. Off (half-duplex) → fillers/bridges in silences only. */
|
|
12663
|
+
aec = false;
|
|
12664
|
+
decideEveryWords = 3;
|
|
12665
|
+
decisionBudgetMs = 300;
|
|
12666
|
+
generateBudgetMs = 500;
|
|
12667
|
+
minGapMs = 2500;
|
|
12668
|
+
/** Backchannel after backchannel needs this gap (~4-6 cues in a 30s monologue — Para phone call 2026-10-13: 5s left
|
|
12669
|
+
* one cue in a long statement). */
|
|
12670
|
+
backchannelGapMs = 3e3;
|
|
12671
|
+
/** Soft cap: at most this many backchannels in any `windowMs` (0 = none). */
|
|
12672
|
+
maxPerWindow = 0;
|
|
12673
|
+
windowMs = 3e4;
|
|
12674
|
+
/** The user resumes within this long of their last words after a line of theirs was dispatched: the same statement goes
|
|
12675
|
+
* on (an STT endpoint mid-monologue) — its words still count toward the backchannel guards, and it is no fresh request. */
|
|
12676
|
+
continueMs = 3e3;
|
|
12677
|
+
/** A decision whose partial grew by this many words while in flight is stale → dropped. */
|
|
12678
|
+
staleWords = 3;
|
|
12679
|
+
microWords = 30;
|
|
12680
|
+
avoidLines = 4;
|
|
12681
|
+
/** Canned picks exclude the last N picks of the same kind. */
|
|
12682
|
+
bankExclude = 2;
|
|
12683
|
+
/** A backchannel needs a substantial chunk first: a turn of ≤8 words is a request/answer, not a story (replay: most
|
|
12684
|
+
* misplaced "mm-hm"s fired at words 6-8 of a short command). */
|
|
12685
|
+
backchannelMinWords = 10;
|
|
12686
|
+
/** A turn that opens as a question/command (requestOpener) holds backchannels until it has grown into an explanation. */
|
|
12687
|
+
requestHoldWords = 18;
|
|
12688
|
+
/** No new interim for this long mid-utterance (a breath / clause gap, shorter than a pause) → a backchannel moment. */
|
|
12689
|
+
backchannelPauseMs = 350;
|
|
12690
|
+
/** A bridge waits this long after the user's turn is dispatched, and plays only if channel-1 has said nothing yet
|
|
12691
|
+
* (a fast reply/ack needs no bridge — one before it reads as the agent starting a different sentence). 0 = at once. */
|
|
12692
|
+
bridgeDelayMs = 1200;
|
|
12693
|
+
/** No new interim for this long → a pause (hosts may also call onPause). */
|
|
12694
|
+
fillerPauseMs = 700;
|
|
12695
|
+
fillerMinWords = 4;
|
|
12696
|
+
bridgeMinWords = 5;
|
|
12697
|
+
/** ANTICIPATE (backchannelPlan.ts): plan the user's next turn off the hot path (after the agent's reply / at turn start,
|
|
12698
|
+
* revised once at `reviseAtWords`); live, a backchannel fires only on a planned cue, at a `cuePauseMs` breath, with the
|
|
12699
|
+
* cue's line and no decider call. A silent plan (short command / question / density none) vetoes backchannels. No plan
|
|
12700
|
+
* yet / failed / late → the reactive path. Off by default (AGENTX_CHANNEL_ANTICIPATE=1). */
|
|
12701
|
+
anticipate = false;
|
|
12702
|
+
plan;
|
|
12703
|
+
planBudgetMs = 4e3;
|
|
12704
|
+
reviseAtWords = 7;
|
|
12705
|
+
/** A predicted moment needs only a short breath (the decision is already made). */
|
|
12706
|
+
cuePauseMs = 200;
|
|
12707
|
+
/** A cue counts only within the last N words (older matches were gated or passed). */
|
|
12708
|
+
cueWindow = 6;
|
|
12709
|
+
/** true: a plan fires on its cues only. false: a non-silent plan with density normal/high also lets the reactive breath
|
|
12710
|
+
* backchannel through (the plan then only vetoes short commands / questions / low density). */
|
|
12711
|
+
cueOnly = true;
|
|
12712
|
+
onPlan = () => {
|
|
12713
|
+
};
|
|
12714
|
+
now = () => performance.now();
|
|
12715
|
+
random = Math.random;
|
|
12716
|
+
};
|
|
12717
|
+
var words2 = (s) => s.trim() ? s.trim().split(/\s+/) : [];
|
|
12718
|
+
function withBudget(p, ms, what, ac) {
|
|
12719
|
+
let t;
|
|
12720
|
+
const late = new Promise((r) => {
|
|
12721
|
+
t = setTimeout(() => {
|
|
12722
|
+
log28.debug(`${what}: over the ${ms}ms budget \u2014 dropped`);
|
|
12723
|
+
ac?.abort();
|
|
12724
|
+
r(null);
|
|
12725
|
+
}, ms);
|
|
12726
|
+
});
|
|
12727
|
+
return Promise.race([p.catch((e) => {
|
|
12728
|
+
const msg = String(e?.message ?? e);
|
|
12729
|
+
if (e?.name === "AbortError" || /aborted/i.test(msg)) log28.debug(`${what}: aborted after its budget`);
|
|
12730
|
+
else log28.warn(`${what} failed \u2014 falling back: ${msg}`);
|
|
12731
|
+
return null;
|
|
12732
|
+
}), late]).finally(() => clearTimeout(t));
|
|
12733
|
+
}
|
|
12734
|
+
var SecondChannel = class {
|
|
12735
|
+
constructor(options) {
|
|
12736
|
+
this.options = options;
|
|
12737
|
+
const preset = CHANNEL_MODES[options?.mode ?? "normal"] ?? {};
|
|
12738
|
+
const base = new SecondChannelOptions();
|
|
12739
|
+
this.options = { ...base, ...preset, ...options };
|
|
12740
|
+
for (const k of Object.keys(preset)) if (!options || !(k in options)) this.normalTiming[k] = base[k];
|
|
12741
|
+
this.startedAt = this.o.now();
|
|
12742
|
+
if (this.o.lang) this.setLanguage(this.o.lang, true);
|
|
12743
|
+
}
|
|
12744
|
+
options;
|
|
12745
|
+
heard = "";
|
|
12746
|
+
userSpeaking = false;
|
|
12747
|
+
main = "idle";
|
|
12748
|
+
gen = 0;
|
|
12749
|
+
// bumped by barge-in / a new user turn → in-flight decisions drop
|
|
12750
|
+
decidedAt = 0;
|
|
12751
|
+
// word count at the last decision
|
|
12752
|
+
busy = false;
|
|
12753
|
+
pauseTimer = null;
|
|
12754
|
+
bcTimer = null;
|
|
12755
|
+
bridgeTimer = null;
|
|
12756
|
+
lastAt = -Infinity;
|
|
12757
|
+
lastKind = null;
|
|
12758
|
+
live = null;
|
|
12759
|
+
// our last line, until channel-1 audio takes the floor
|
|
12760
|
+
cutLine = null;
|
|
12761
|
+
// the line the last barge actually cut (spoke())
|
|
12762
|
+
bridged = false;
|
|
12763
|
+
// one bridge per main turn
|
|
12764
|
+
recent = [];
|
|
12765
|
+
picks = /* @__PURE__ */ new Map();
|
|
12766
|
+
inflight = null;
|
|
12767
|
+
startedAt;
|
|
12768
|
+
lang = null;
|
|
12769
|
+
carried = 0;
|
|
12770
|
+
// words of this statement already dispatched (a monologue split by STT endpoints)
|
|
12771
|
+
floorWords = 0;
|
|
12772
|
+
// carried + the last dispatched line's words
|
|
12773
|
+
lastHeardAt = -Infinity;
|
|
12774
|
+
pinned = false;
|
|
12775
|
+
// anticipate: the plan for the user's current/next turn (turnNo bumps per dispatched turn → a late plan is dropped)
|
|
12776
|
+
turnNo = 0;
|
|
12777
|
+
plan = null;
|
|
12778
|
+
planning = false;
|
|
12779
|
+
planTries = 0;
|
|
12780
|
+
// per turn: the reply-end plan + one turn-start retry, never a call per interim
|
|
12781
|
+
revised = false;
|
|
12782
|
+
cueFrom = 0;
|
|
12783
|
+
// word index after the last planned backchannel
|
|
12784
|
+
reply = "";
|
|
12785
|
+
// channel-1's text this turn (the plan's context)
|
|
12786
|
+
lastUser = "";
|
|
12787
|
+
lastReactAt = -Infinity;
|
|
12788
|
+
normalTiming = {};
|
|
12789
|
+
// chatty without aec: the preset's keys back at their defaults
|
|
12790
|
+
degradedLogged = false;
|
|
12791
|
+
/** The language lines are spoken in (detected from the user's words, or set by the host). */
|
|
12792
|
+
get language() {
|
|
12793
|
+
return this.lang;
|
|
12794
|
+
}
|
|
12795
|
+
/** Switch language: its bank starts loading in the background. `host` = the host's STT-reported language — it pins
|
|
12796
|
+
* the language (later detection never overrides it; only another host call does). Tags normalize to 'he-IL' → 'he'. */
|
|
12797
|
+
setLanguage(tag, host = false) {
|
|
12798
|
+
if (!tag || this.pinned && !host) return;
|
|
12799
|
+
if (host) this.pinned = true;
|
|
12800
|
+
const lang = primaryLang(tag);
|
|
12801
|
+
if (!lang || lang === this.lang) return;
|
|
12802
|
+
log28.debug(`language ${this.lang} \u2192 ${lang}`);
|
|
12803
|
+
this.lang = lang;
|
|
12804
|
+
this.recent = [];
|
|
12805
|
+
this.picks.clear();
|
|
12806
|
+
void this.o.banks?.ensure(lang);
|
|
12807
|
+
}
|
|
12808
|
+
/** Once a language is known, switching needs strong evidence (detectLanguage's `switching`): an English "Um," read as
|
|
12809
|
+
* Portuguese flipped a live session to pt (silence until a pt bank was generated — it then spoke Portuguese). */
|
|
12810
|
+
detect(text) {
|
|
12811
|
+
if (this.pinned) return;
|
|
12812
|
+
const l = detectLanguage(text, !!this.lang);
|
|
12813
|
+
if (l) this.setLanguage(l);
|
|
12814
|
+
}
|
|
12815
|
+
get bank() {
|
|
12816
|
+
return this.lang ? this.o.banks ? this.o.banks.get(this.lang) : this.lang === "en" ? EN_BANK : null : null;
|
|
12817
|
+
}
|
|
12818
|
+
get o() {
|
|
12819
|
+
const o = this.options;
|
|
12820
|
+
return o.mode !== "chatty" || o.aec ? o : { ...o, ...this.normalTiming };
|
|
12821
|
+
}
|
|
12822
|
+
/** The user's live partial for the current turn (the whole partial, not a delta). */
|
|
12823
|
+
onInterim(text) {
|
|
12824
|
+
const t = text.trim();
|
|
12825
|
+
if (!t || t === this.heard) return;
|
|
12826
|
+
if (this.main !== "idle" || !this.heard) this.carried = this.o.now() - this.lastHeardAt < this.o.continueMs ? this.floorWords : 0;
|
|
12827
|
+
this.lastHeardAt = this.o.now();
|
|
12828
|
+
if (this.main !== "idle") this.onAgentState("idle");
|
|
12829
|
+
this.heard = t;
|
|
12830
|
+
this.detect(t);
|
|
12831
|
+
this.userSpeaking = true;
|
|
12832
|
+
if (this.pauseTimer) clearTimeout(this.pauseTimer);
|
|
12833
|
+
this.pauseTimer = setTimeout(() => {
|
|
12834
|
+
this.pauseTimer = null;
|
|
12835
|
+
this.onPause(this.o.fillerPauseMs);
|
|
12836
|
+
}, this.o.fillerPauseMs);
|
|
12837
|
+
let cue = null;
|
|
12838
|
+
if (this.o.anticipate) {
|
|
12839
|
+
const n = words2(t).length;
|
|
12840
|
+
if (!this.plan && !this.planning && this.planTries < 2) this.requestPlan();
|
|
12841
|
+
else if (this.plan && !this.revised && n >= this.o.reviseAtWords) {
|
|
12842
|
+
this.revised = true;
|
|
12843
|
+
this.requestPlan(t);
|
|
12844
|
+
}
|
|
12845
|
+
cue = matchCue(this.plan, t, Math.max(this.cueFrom, n - this.o.cueWindow));
|
|
12846
|
+
}
|
|
12847
|
+
if (this.bcTimer) clearTimeout(this.bcTimer);
|
|
12848
|
+
this.bcTimer = this.o.aec ? setTimeout(() => {
|
|
12849
|
+
this.bcTimer = null;
|
|
12850
|
+
if (this.userSpeaking && this.heard === t) void this.consider("interim", 0, true, cue);
|
|
12851
|
+
}, cue ? this.o.cuePauseMs : this.o.backchannelPauseMs) : null;
|
|
12852
|
+
if (words2(t).length - this.decidedAt >= this.o.decideEveryWords) void this.consider("interim");
|
|
12853
|
+
}
|
|
12854
|
+
/** The user went quiet for `ms` (host VAD/STT pause, or the internal interim timer). */
|
|
12855
|
+
onPause(ms) {
|
|
12856
|
+
if (this.pauseTimer) {
|
|
12857
|
+
clearTimeout(this.pauseTimer);
|
|
12858
|
+
this.pauseTimer = null;
|
|
12859
|
+
}
|
|
12860
|
+
if (this.bcTimer) {
|
|
12861
|
+
clearTimeout(this.bcTimer);
|
|
12862
|
+
this.bcTimer = null;
|
|
12863
|
+
}
|
|
12864
|
+
this.userSpeaking = false;
|
|
12865
|
+
void this.consider("pause", ms);
|
|
12866
|
+
}
|
|
12867
|
+
/** Channel-1 lifecycle. `queued` = the user's turn was dispatched to the main agent. */
|
|
12868
|
+
onAgentState(s) {
|
|
12869
|
+
if (s === this.main) return;
|
|
12870
|
+
this.main = s;
|
|
12871
|
+
if (s === "first_audio" || s === "speaking") this.live = null;
|
|
12872
|
+
if (s !== "queued" && s !== "thinking" && this.bridgeTimer) {
|
|
12873
|
+
clearTimeout(this.bridgeTimer);
|
|
12874
|
+
this.bridgeTimer = null;
|
|
12875
|
+
}
|
|
12876
|
+
if (s === "idle") {
|
|
12877
|
+
this.heard = "";
|
|
12878
|
+
this.decidedAt = 0;
|
|
12879
|
+
this.bridged = false;
|
|
12880
|
+
this.cueFrom = 0;
|
|
12881
|
+
if (this.o.anticipate && this.reply.trim() && !this.plan && !this.planning) this.requestPlan();
|
|
12882
|
+
return;
|
|
12883
|
+
}
|
|
12884
|
+
if (s === "queued") {
|
|
12885
|
+
this.turnNo++;
|
|
12886
|
+
this.plan = null;
|
|
12887
|
+
this.planning = false;
|
|
12888
|
+
this.planTries = 0;
|
|
12889
|
+
this.revised = false;
|
|
12890
|
+
this.reply = "";
|
|
12891
|
+
this.gen++;
|
|
12892
|
+
this.userSpeaking = false;
|
|
12893
|
+
this.bridged = false;
|
|
12894
|
+
if (this.lastKind === "bridge") this.lastKind = null;
|
|
12895
|
+
for (const k of ["pauseTimer", "bcTimer", "bridgeTimer"]) {
|
|
12896
|
+
if (this[k]) clearTimeout(this[k]);
|
|
12897
|
+
this[k] = null;
|
|
12898
|
+
}
|
|
12899
|
+
const wait = Math.max(this.o.bridgeDelayMs, this.o.minGapMs - (this.o.now() - this.lastAt));
|
|
12900
|
+
if (wait > 0) this.bridgeTimer = setTimeout(() => {
|
|
12901
|
+
this.bridgeTimer = null;
|
|
12902
|
+
void this.consider("working");
|
|
12903
|
+
}, wait);
|
|
12904
|
+
else void this.consider("working");
|
|
12905
|
+
}
|
|
12906
|
+
}
|
|
12907
|
+
/** The user's final line was dispatched to the main agent (its words are the bridge's micro-context). */
|
|
12908
|
+
onUserTurn(text) {
|
|
12909
|
+
this.heard = text.trim();
|
|
12910
|
+
this.floorWords = this.carried + words2(this.heard).length;
|
|
12911
|
+
this.lastUser = this.heard;
|
|
12912
|
+
this.detect(this.heard);
|
|
12913
|
+
this.main = "idle";
|
|
12914
|
+
this.onAgentState("queued");
|
|
12915
|
+
}
|
|
12916
|
+
/** The main answer's streamed text (before its audio): channel-1 is responding — no bridge from here on. */
|
|
12917
|
+
onMainTokens(text) {
|
|
12918
|
+
this.noteReply(text);
|
|
12919
|
+
if (text.trim() && (this.main === "queued" || this.main === "thinking")) this.onAgentState("streaming_text");
|
|
12920
|
+
}
|
|
12921
|
+
/** Channel-1's reply text (a delta) — only the plan's context; no state change (hosts without onMainTokens). */
|
|
12922
|
+
noteReply(text) {
|
|
12923
|
+
this.reply += text;
|
|
12924
|
+
}
|
|
12925
|
+
/** The user barged in: cut the in-flight line now and drop every pending decision. */
|
|
12926
|
+
onBargeIn() {
|
|
12927
|
+
this.gen++;
|
|
12928
|
+
this.cutLine = this.inflight || this.live ? this.live : null;
|
|
12929
|
+
if (this.inflight) {
|
|
12930
|
+
this.inflight = null;
|
|
12931
|
+
try {
|
|
12932
|
+
this.o.cancel();
|
|
12933
|
+
} catch (e) {
|
|
12934
|
+
log28.warn(`cancel threw: ${e.message}`);
|
|
12935
|
+
}
|
|
12936
|
+
}
|
|
12937
|
+
}
|
|
12938
|
+
/** Is `text` the line the last barge-in cut (ours, still holding the floor)? Hosts strip it from the cut reply — it is
|
|
12939
|
+
* not the agent's speech. By identity, not by text: a real reply that merely repeats a recent line is kept. Consumed once. */
|
|
12940
|
+
spoke(text) {
|
|
12941
|
+
const c = this.cutLine;
|
|
12942
|
+
this.cutLine = null;
|
|
12943
|
+
return !!c && c === text.trim();
|
|
12944
|
+
}
|
|
12945
|
+
/** Resolves when the in-flight line finished playing (immediately when none) — hosts enqueue channel-1 audio behind it. */
|
|
12946
|
+
drain() {
|
|
12947
|
+
return this.inflight ?? Promise.resolve();
|
|
12948
|
+
}
|
|
12949
|
+
/** Channel-1's last reply ended on a CLOSED question (yes/no, "X or Y?", a confirm): the user's current turn answers or
|
|
12950
|
+
* corrects it. An open invitation ("what happened?") is different — a story follows, and reactions fit it. */
|
|
12951
|
+
get answeringUs() {
|
|
12952
|
+
return this.main === "idle" && closedQuestion(this.reply);
|
|
12953
|
+
}
|
|
12954
|
+
get audioLocked() {
|
|
12955
|
+
return this.main === "first_audio" || this.main === "speaking";
|
|
12956
|
+
}
|
|
12957
|
+
tail() {
|
|
12958
|
+
return words2(this.heard).slice(-this.o.microWords).join(" ");
|
|
12959
|
+
}
|
|
12960
|
+
/** Hard gates every line passes, whoever proposed it. */
|
|
12961
|
+
allowed(k) {
|
|
12962
|
+
if (!this.o.kinds.includes(k)) return false;
|
|
12963
|
+
if (this.audioLocked || this.inflight || this.o.now() - this.lastAt < this.o.minGapMs || k === this.lastKind && (k !== "backchannel" || this.o.now() - this.lastAt < this.o.backchannelGapMs)) return false;
|
|
12964
|
+
if (k === "backchannel") return this.o.aec && this.userSpeaking && this.main === "idle" && !this.capped();
|
|
12965
|
+
if (k === "filler") return !this.userSpeaking && this.main === "idle";
|
|
12966
|
+
return (this.main === "queued" || this.main === "thinking") && !this.bridged;
|
|
12967
|
+
}
|
|
12968
|
+
bcTimes = [];
|
|
12969
|
+
capped() {
|
|
12970
|
+
if (!this.o.maxPerWindow) return false;
|
|
12971
|
+
const from = this.o.now() - this.o.windowMs;
|
|
12972
|
+
this.bcTimes = this.bcTimes.filter((t) => t > from);
|
|
12973
|
+
return this.bcTimes.length >= this.o.maxPerWindow;
|
|
12974
|
+
}
|
|
12975
|
+
/** 0ms rule: the candidate line for this trigger, or silence. */
|
|
12976
|
+
rule(trigger, pauseMs = 0, breath = false, cue = null) {
|
|
12977
|
+
const w = words2(this.heard);
|
|
12978
|
+
if (!this.lang) return "silence";
|
|
12979
|
+
if (w.length <= 3 || w.length <= 6 && isPhatic(this.heard, this.bank)) return "silence";
|
|
12980
|
+
const k = trigger === "interim" ? this.backchannelMoment(w.length, breath) && (!this.o.anticipate || !this.plan || !silentTurn(this.plan) && (!!cue || !this.o.cueOnly && this.plan.density !== "low")) ? "backchannel" : null : trigger === "pause" ? pauseMs >= this.o.fillerPauseMs && w.length >= this.o.fillerMinWords ? "filler" : null : w.length >= this.o.bridgeMinWords ? "bridge" : null;
|
|
12981
|
+
return k && this.allowed(k) ? k : "silence";
|
|
12982
|
+
}
|
|
12983
|
+
/** Plan (or, with `heard`, revise) the backchannels of the user's turn — async, never on the hot path. */
|
|
12984
|
+
requestPlan(heard) {
|
|
12985
|
+
if (!this.o.plan || !this.lang) return;
|
|
12986
|
+
const turn = this.turnNo, t0 = this.o.now(), ac = new AbortController();
|
|
12987
|
+
if (!heard) {
|
|
12988
|
+
this.planning = true;
|
|
12989
|
+
this.planTries++;
|
|
12990
|
+
}
|
|
12991
|
+
const ctx = { agent: this.reply.trim(), user: words2(this.lastUser).slice(-30).join(" "), recent: [...this.recent], lang: this.lang, heard };
|
|
12992
|
+
void withBudget(this.o.plan(ctx, ac.signal), this.o.planBudgetMs, heard ? "plan revision" : "plan").then((p) => {
|
|
12993
|
+
if (p === null) ac.abort();
|
|
12994
|
+
if (turn !== this.turnNo) return;
|
|
12995
|
+
if (!heard) this.planning = false;
|
|
12996
|
+
if (!p) return;
|
|
12997
|
+
this.plan = p;
|
|
12998
|
+
this.o.onPlan(p, !!heard, Math.round(this.o.now() - t0));
|
|
12999
|
+
});
|
|
13000
|
+
}
|
|
13001
|
+
/** A clause boundary mid-floor: a breath or a comma-like close. Not a question (it wants the answer, not "mm-hm"), and a
|
|
13002
|
+
* sentence end only deep into a story — a short request's end is the end of their turn (replay: "mm-hm" right after
|
|
13003
|
+
* "Check hacker news for recent news.", just before the final). */
|
|
13004
|
+
backchannelMoment(n, breath) {
|
|
13005
|
+
if (/\?$/.test(this.heard) || pendingQuestion(this.heard)) return false;
|
|
13006
|
+
if (this.carried) n += this.carried;
|
|
13007
|
+
else if (n < this.o.requestHoldWords && requestOpener(this.heard)) return false;
|
|
13008
|
+
if (n < this.o.backchannelMinWords) return false;
|
|
13009
|
+
if (/[.!]$/.test(this.heard)) return n >= 2 * this.o.backchannelMinWords;
|
|
13010
|
+
return breath || /[,;:…]$/.test(this.heard);
|
|
13011
|
+
}
|
|
13012
|
+
async consider(trigger, pauseMs, breath = false, cue = null) {
|
|
13013
|
+
if (this.busy) return;
|
|
13014
|
+
const n0 = words2(this.heard).length;
|
|
13015
|
+
this.decidedAt = n0;
|
|
13016
|
+
if (!(this.o.anticipate && this.plan && trigger === "interim")) cue = null;
|
|
13017
|
+
const opts = this.options;
|
|
13018
|
+
if (opts.mode === "chatty" && !opts.aec && !this.degradedLogged) {
|
|
13019
|
+
this.degradedLogged = true;
|
|
13020
|
+
log28.info("chatty mode without echo cancellation \u2014 normal timing (our lines would re-trigger the mic)");
|
|
13021
|
+
}
|
|
13022
|
+
const rule = this.rule(trigger, pauseMs, breath, cue);
|
|
13023
|
+
if (rule === "silence") return;
|
|
13024
|
+
this.busy = true;
|
|
13025
|
+
const gen = this.gen, t0 = this.o.now();
|
|
13026
|
+
const ev = (e) => this.o.onEvent({ trigger, rule, ms: Math.round(this.o.now() - t0), ...cue ? { cue: cue.line } : {}, ...e });
|
|
13027
|
+
const stale = () => gen !== this.gen || words2(this.heard).length - n0 >= this.o.staleWords;
|
|
13028
|
+
try {
|
|
13029
|
+
let kind = rule;
|
|
13030
|
+
let choice;
|
|
13031
|
+
if (this.o.decide && !cue) {
|
|
13032
|
+
const ac = new AbortController();
|
|
13033
|
+
const d = await withBudget(this.o.decide(this.tail(), { ...this.state(pauseMs), rule }, ac.signal), this.o.decisionBudgetMs, "channel decision", ac);
|
|
13034
|
+
choice = d?.choice;
|
|
13035
|
+
if (choice && choice in CHANNEL_ACTIONS && (choice === "silence" || this.allowed(choice))) kind = choice;
|
|
13036
|
+
else if (choice && choice in CHANNEL_ACTIONS) log28.debug(`decider chose ${choice} for a ${rule} moment \u2014 the rule stands`);
|
|
13037
|
+
}
|
|
13038
|
+
if (kind === "silence") return ev({ choice, acted: "silence" });
|
|
13039
|
+
if (stale()) return ev({ choice, kind, acted: "stale" });
|
|
13040
|
+
if (!this.allowed(kind)) return ev({ choice, kind, acted: "gated" });
|
|
13041
|
+
const react = kind === "backchannel" && !this.answeringUs && this.o.now() - this.lastReactAt >= this.o.reactionGapMs;
|
|
13042
|
+
const text = cue && react && !this.recent.some((r) => norm3(r) === norm3(cue.line)) ? cue.line : await this.line(kind, react);
|
|
13043
|
+
if (!text) return ev({ choice, kind, acted: "no_bank" });
|
|
13044
|
+
if (stale()) return ev({ choice, kind, text, acted: "stale" });
|
|
13045
|
+
if (!this.allowed(kind)) return ev({ choice, kind, text, acted: "gated" });
|
|
13046
|
+
const said = this.say(kind, text);
|
|
13047
|
+
if (said && cue) this.cueFrom = words2(this.heard).length;
|
|
13048
|
+
if (said && kind === "backchannel" && this.o.reactionGapMs && !this.bank?.backchannel.some((b) => norm3(b) === norm3(text))) this.lastReactAt = this.o.now();
|
|
13049
|
+
ev({ choice, kind, text, acted: said ? "spoken" : "skipped" });
|
|
13050
|
+
} finally {
|
|
13051
|
+
this.busy = false;
|
|
13052
|
+
}
|
|
13053
|
+
}
|
|
13054
|
+
state(pauseMs) {
|
|
13055
|
+
const now3 = this.o.now();
|
|
13056
|
+
return {
|
|
13057
|
+
user_speaking: this.userSpeaking,
|
|
13058
|
+
pause_ms: pauseMs ?? null,
|
|
13059
|
+
agent_state: this.main,
|
|
13060
|
+
last_line: this.recent.at(-1) ?? null,
|
|
13061
|
+
ms_since_last_line: Number.isFinite(this.lastAt) ? Math.round(now3 - this.lastAt) : null,
|
|
13062
|
+
call_ms: Math.round(now3 - this.startedAt)
|
|
13063
|
+
};
|
|
13064
|
+
}
|
|
13065
|
+
async line(kind, react = true) {
|
|
13066
|
+
if (this.o.generate && this.o.generateFor.includes(kind) && (kind !== "backchannel" || react)) {
|
|
13067
|
+
const ac = new AbortController();
|
|
13068
|
+
const ctx = { kind, heard: this.tail(), state: this.main, avoid: [...this.recent], lang: this.lang, ...kind === "backchannel" && this.o.reactionGapMs ? { react: true } : {} };
|
|
13069
|
+
const raw = await withBudget(this.o.generate(ctx, ac.signal), this.o.generateBudgetMs, `${kind} generate`);
|
|
13070
|
+
if (raw === null) ac.abort();
|
|
13071
|
+
const text = cleanLine(raw ?? "", this.lang);
|
|
13072
|
+
if (text && !this.recent.some((r) => norm3(r) === norm3(text))) return text;
|
|
13073
|
+
if (text) log28.debug(`generated "${text}" repeats a recent line \u2014 canned`);
|
|
13074
|
+
}
|
|
13075
|
+
return this.pick(kind);
|
|
13076
|
+
}
|
|
13077
|
+
/** Canned rotation in the user's language: never one of the last `bankExclude` picks of this kind, nor a recent
|
|
13078
|
+
* line. '' = no bank for this language yet (silence). */
|
|
13079
|
+
pick(kind) {
|
|
13080
|
+
const pool = this.bank?.[kind] ?? [];
|
|
13081
|
+
if (!pool.length) return "";
|
|
13082
|
+
const hist = this.picks.get(kind) ?? [];
|
|
13083
|
+
const recent = new Set([...hist.slice(-this.o.bankExclude), ...this.recent].map(norm3));
|
|
13084
|
+
const fresh = pool.filter((x) => !recent.has(norm3(x)));
|
|
13085
|
+
const from = fresh.length ? fresh : pool;
|
|
13086
|
+
const p = from[Math.floor(this.o.random() * from.length)];
|
|
13087
|
+
this.picks.set(kind, [...hist, p].slice(-this.o.bankExclude));
|
|
13088
|
+
return p;
|
|
13089
|
+
}
|
|
13090
|
+
say(kind, text) {
|
|
13091
|
+
let r;
|
|
13092
|
+
try {
|
|
13093
|
+
r = this.o.speak(text, kind);
|
|
13094
|
+
} catch (e) {
|
|
13095
|
+
log28.warn(`speak threw \u2014 line dropped: ${e.message}`);
|
|
13096
|
+
return false;
|
|
13097
|
+
}
|
|
13098
|
+
if (r === false) return false;
|
|
13099
|
+
this.lastAt = this.o.now();
|
|
13100
|
+
this.lastKind = kind;
|
|
13101
|
+
if (kind === "backchannel") this.bcTimes.push(this.lastAt);
|
|
13102
|
+
if (kind === "bridge") this.bridged = true;
|
|
13103
|
+
this.recent = [...this.recent, text].slice(-this.o.avoidLines);
|
|
13104
|
+
this.live = text.trim();
|
|
13105
|
+
this.o.show(text, kind);
|
|
13106
|
+
const p = Promise.resolve(r).then(() => {
|
|
13107
|
+
}, (e) => {
|
|
13108
|
+
log28.warn(`channel line playback failed: ${e?.message ?? e}`);
|
|
13109
|
+
}).finally(() => {
|
|
13110
|
+
if (this.inflight === p) this.inflight = null;
|
|
13111
|
+
});
|
|
13112
|
+
this.inflight = p;
|
|
13113
|
+
return true;
|
|
13114
|
+
}
|
|
13115
|
+
};
|
|
13116
|
+
var FAST_ASK = {
|
|
13117
|
+
backchannel: 'The user is still talking and just took a breath. Would an attentive human listener say a tiny "mm-hm" now? Yes ONLY if they are telling a story/explaining across several clauses. No if it is a request, command, question, or they are mid-request.',
|
|
13118
|
+
filler: 'The user went quiet mid-turn. Would a human listener make a soft "hmm\u2026" now? Yes ONLY if their sentence is unfinished and they are searching for words. No if they said a complete request, question, answer, thanks or remark (the reply comes next).',
|
|
13119
|
+
bridge: `The user finished and the assistant's answer is taking a moment. Would a human say a short "let me check\u2014" now? Yes if they asked for real work or a substantial/hard question. No for small talk, a greeting, thanks, an ok/yes/agreement.`
|
|
13120
|
+
};
|
|
13121
|
+
function fastChannelDecider(ai, model = "groq/qwen/qwen3.8-27b") {
|
|
13122
|
+
return async (heard, state, signal) => {
|
|
13123
|
+
const kind = state.rule;
|
|
13124
|
+
if (!(kind in FAST_ASK)) return null;
|
|
13125
|
+
const prompt = `Live voice call. ${FAST_ASK[kind]}
|
|
13126
|
+
So far the user said: "${heard}"
|
|
13127
|
+
Answer yes or no.`;
|
|
13128
|
+
const reasoning = /qwen/.test(model) ? { providerOptions: { reasoning_effort: "none" } } : /gpt-oss/.test(model) ? { providerOptions: { reasoning_effort: "low" } } : {};
|
|
13129
|
+
const r = await ai.chat({ model, messages: [{ role: "user", content: prompt }], stream: false, signal, temperature: 0, maxTokens: /gpt-oss/.test(model) ? 200 : 3, ...reasoning });
|
|
13130
|
+
const a = (r.content ?? "").trim().toLowerCase();
|
|
13131
|
+
return /^yes/.test(a) ? { choice: kind } : /^no/.test(a) ? { choice: "silence" } : null;
|
|
13132
|
+
};
|
|
13133
|
+
}
|
|
13134
|
+
function channelDecide(ai, instinct, env = process.env) {
|
|
13135
|
+
const model = env.AGENTX_CHANNEL_DECIDER_MODEL || void 0;
|
|
13136
|
+
if (model || env.GROQ_API_KEY) return fastChannelDecider(ai, model);
|
|
13137
|
+
return instinct ? (heard, state) => instinct.channel(heard, state) : void 0;
|
|
13138
|
+
}
|
|
13139
|
+
var KIND_HINT2 = {
|
|
13140
|
+
backchannel: 'a tiny listener cue while they keep talking ("mm-hm", "right", "yeah") \u2014 shows you follow, never takes the turn',
|
|
13141
|
+
filler: 'a soft listener sound while THEY search for words ("mm\u2026", "mhm", "yeah\u2026") \u2014 never a word that continues their sentence or starts yours',
|
|
13142
|
+
bridge: 'a neutral one-moment cue while the answer is prepared ("let me see\u2026", "one sec\u2026") \u2014 never the start of a statement or opinion ("the thing is", "well actually")'
|
|
13143
|
+
};
|
|
13144
|
+
var REACT_HINT = 'a short reaction to their LAST words instead: feeling ("oh no", "ha, nice", "ugh"), surprise ("wait, really?", "huh"), a light laugh if they joke or laugh ("ha!"), or an echo of their key word ("the deploy?") \u2014 still never asks for an answer or takes the turn; if nothing there deserves a reaction, a plain "mm-hm"';
|
|
13145
|
+
function fastLineGenerator(ai, model) {
|
|
13146
|
+
return async (c, signal) => {
|
|
13147
|
+
const prompt = `You voice the tiny side-sounds of a live spoken conversation. Write ONE line of 1-5 words: ${KIND_HINT2[c.kind]}.
|
|
13148
|
+
` + (c.react ? `Here the line may be ${REACT_HINT}.
|
|
13149
|
+
` : "") + `The user just said: "${c.heard}"
|
|
13150
|
+
The assistant is: ${c.state}.
|
|
13151
|
+
` + (c.avoid.length ? `Do not reuse any of: ${c.avoid.map((a) => `"${a}"`).join(", ")}.
|
|
13152
|
+
` : "") + `React to their actual words, casually, in ${languageName(c.lang)}. Output only the line.`;
|
|
13153
|
+
const reasoning = /gpt-oss/.test(model) ? { providerOptions: { reasoning_effort: "low" } } : {};
|
|
13154
|
+
const r = await ai.chat({ model, messages: [{ role: "user", content: prompt }], stream: false, signal, maxTokens: 150, ...reasoning });
|
|
13155
|
+
return r.content ?? "";
|
|
13156
|
+
};
|
|
13157
|
+
}
|
|
13158
|
+
function closedQuestion(reply) {
|
|
13159
|
+
const t = reply.replace(/\[[^\]]*\]|<\/?[a-z]+>/gi, " ").replace(/\s+/g, " ").trim();
|
|
13160
|
+
if (!/\?["'’”)]*$/.test(t)) return false;
|
|
13161
|
+
const q = t.split(/(?<=[.!?])\s+(?=\S)/).pop().replace(/^(?:(?:so|ok(?:ay)?|and|but|just to (?:confirm|check)|to confirm|quick (?:check|question)|got it)[,:—–-]*\s+)+/i, "");
|
|
13162
|
+
return /^(?:should|shall|do|does|did|is|are|was|were|can|could|would|will|may|have|has|want|wanna)\b/i.test(q) || /\bor\b/i.test(q);
|
|
13163
|
+
}
|
|
13164
|
+
|
|
13165
|
+
// src/voice/call.ts
|
|
13166
|
+
var log29 = forComponent("VoiceCall");
|
|
13167
|
+
var VoiceCallOptions = class {
|
|
13168
|
+
/** Host runs MAIN with exactly `text` (a replace: roll the `replaces` turn back first). */
|
|
13169
|
+
sendTurn = () => {
|
|
13170
|
+
};
|
|
13171
|
+
/** Host aborts a running MAIN turn (barge-in). */
|
|
13172
|
+
abortTurn = () => {
|
|
13173
|
+
};
|
|
13174
|
+
/** Host TTS (fill lane): resolves when played; `false` = could not play. */
|
|
13175
|
+
speak = () => false;
|
|
13176
|
+
/** Stop the in-flight fill line now (barge-in only). */
|
|
13177
|
+
cancel = () => {
|
|
13178
|
+
};
|
|
13179
|
+
/** Host caption — never history. */
|
|
13180
|
+
show = () => {
|
|
13181
|
+
};
|
|
13182
|
+
/** The fast model (lines, banks, progress) — used only with `ai`. */
|
|
13183
|
+
fastModel = latestClaude("haiku");
|
|
13184
|
+
/** Model client for the fast model + the channel decider / overlap judge. Absent → canned lines and rules only. */
|
|
13185
|
+
ai;
|
|
13186
|
+
/** Cache dir for per-language banks (ChannelBanks). */
|
|
13187
|
+
bankCacheDir;
|
|
13188
|
+
/** The host STT's language (BCP-47) — the starting language; each turn's own words still decide. */
|
|
13189
|
+
lang;
|
|
13190
|
+
aec = false;
|
|
13191
|
+
mode = "normal";
|
|
13192
|
+
gate = {};
|
|
13193
|
+
progress = {};
|
|
13194
|
+
/** Extra SecondChannel options (tests, tuning). speak/show/cancel are always VoiceCall's own. */
|
|
13195
|
+
channel = {};
|
|
13196
|
+
onMetric = () => {
|
|
13197
|
+
};
|
|
13198
|
+
now = () => performance.now();
|
|
13199
|
+
};
|
|
13200
|
+
var VoiceCall = class {
|
|
13201
|
+
constructor(options) {
|
|
13202
|
+
this.options = options;
|
|
13203
|
+
this.options = { ...new VoiceCallOptions(), ...options };
|
|
13204
|
+
const o = this.o;
|
|
13205
|
+
if (o.lang) this.lang = primaryLang(o.lang);
|
|
13206
|
+
const ai = o.ai;
|
|
13207
|
+
this.fill = new SecondChannel({
|
|
13208
|
+
...ai ? {
|
|
13209
|
+
banks: new ChannelBanks({ generate: fastBankGenerator(ai, o.fastModel), ...o.bankCacheDir ? { cacheDir: o.bankCacheDir } : {} }),
|
|
13210
|
+
decide: channelDecide(ai),
|
|
13211
|
+
generate: fastLineGenerator(ai, o.fastModel)
|
|
13212
|
+
} : {},
|
|
13213
|
+
mode: o.mode,
|
|
13214
|
+
aec: o.aec,
|
|
13215
|
+
...this.lang ? { lang: this.lang } : {},
|
|
13216
|
+
...o.channel,
|
|
13217
|
+
speak: (text, kind) => this.say(text, kind),
|
|
13218
|
+
show: (text, kind) => o.show(text, kind),
|
|
13219
|
+
cancel: () => o.cancel(),
|
|
13220
|
+
now: o.now
|
|
13221
|
+
});
|
|
13222
|
+
this.progress = new Progress({ ...ai ? { generate: fastProgressGenerator(ai, o.fastModel) } : {}, now: o.now, ...o.progress });
|
|
13223
|
+
this.judge = new OverlapJudge({ ...ai ? { model: overlapModelFor(ai) } : {}, now: o.now });
|
|
13224
|
+
this.gate = new TurnGate({
|
|
13225
|
+
now: o.now,
|
|
13226
|
+
...o.gate,
|
|
13227
|
+
onTurn: (t) => this.onTurn(t),
|
|
13228
|
+
onDrop: (text, why) => this.onDrop(text, why),
|
|
13229
|
+
onHold: (text) => o.onMetric({ name: "hold", text })
|
|
13230
|
+
});
|
|
13231
|
+
}
|
|
13232
|
+
options;
|
|
13233
|
+
fill;
|
|
13234
|
+
gate;
|
|
13235
|
+
progress;
|
|
13236
|
+
judge;
|
|
13237
|
+
cur;
|
|
13238
|
+
lang = null;
|
|
13239
|
+
lastAudibleAt = -Infinity;
|
|
13240
|
+
firstFinalAt = null;
|
|
13241
|
+
// the first final of the turn being formed (hold/merge)
|
|
13242
|
+
progressTimer = null;
|
|
13243
|
+
ended = false;
|
|
13244
|
+
get o() {
|
|
13245
|
+
return this.options;
|
|
13246
|
+
}
|
|
13247
|
+
/** The language the fill channel speaks right now (null = unknown → silent). */
|
|
13248
|
+
get language() {
|
|
13249
|
+
return this.lang;
|
|
13250
|
+
}
|
|
13251
|
+
/** The MAIN turn in flight (or the last one). */
|
|
13252
|
+
get turn() {
|
|
13253
|
+
return this.cur && { id: this.cur.id, text: this.cur.text, running: this.cur.running };
|
|
13254
|
+
}
|
|
13255
|
+
/** The host STT's language (BCP-47): the current language until the user's words say otherwise. */
|
|
13256
|
+
setLanguage(tag) {
|
|
13257
|
+
const l = primaryLang(tag);
|
|
13258
|
+
if (!l) return;
|
|
13259
|
+
this.lang = l;
|
|
13260
|
+
this.fill.setLanguage(l, true);
|
|
13261
|
+
}
|
|
13262
|
+
/** STT partial (the whole partial). */
|
|
13263
|
+
interim(text, _atMs, aec) {
|
|
13264
|
+
if (this.ended || !text.trim()) return;
|
|
13265
|
+
if (typeof aec === "boolean") this.fill.options.aec = aec;
|
|
13266
|
+
this.gate.speaking();
|
|
13267
|
+
this.follow(text);
|
|
13268
|
+
this.fill.onInterim(text);
|
|
13269
|
+
}
|
|
13270
|
+
/** STT final → TurnGate. */
|
|
13271
|
+
final(text, sttId) {
|
|
13272
|
+
if (this.ended) return;
|
|
13273
|
+
this.firstFinalAt ??= this.o.now();
|
|
13274
|
+
this.gate.final(text, sttId);
|
|
13275
|
+
}
|
|
13276
|
+
/** A v2 host that decides turns itself: the user's line already went to MAIN (no TurnGate, no sendTurn) — the fill
|
|
13277
|
+
* channel follows its language and covers the wait. */
|
|
13278
|
+
hostTurn(text) {
|
|
13279
|
+
if (this.ended || !text.trim()) return;
|
|
13280
|
+
this.follow(text);
|
|
13281
|
+
this.fill.onUserTurn(text);
|
|
13282
|
+
}
|
|
13283
|
+
pause(ms) {
|
|
13284
|
+
if (!this.ended) this.fill.onPause(ms);
|
|
13285
|
+
}
|
|
13286
|
+
/** MAIN's stream. */
|
|
13287
|
+
main(ev) {
|
|
13288
|
+
const c = this.cur;
|
|
13289
|
+
if (!c || ev.id && ev.id !== c.id) return;
|
|
13290
|
+
switch (ev.type) {
|
|
13291
|
+
case "start":
|
|
13292
|
+
c.running = true;
|
|
13293
|
+
return;
|
|
13294
|
+
case "text":
|
|
13295
|
+
if (!ev.text) return;
|
|
13296
|
+
if (!c.streamed) {
|
|
13297
|
+
c.streamed = true;
|
|
13298
|
+
this.gate.replyStarted(c.id);
|
|
13299
|
+
this.progress.text();
|
|
13300
|
+
this.clearProgress();
|
|
13301
|
+
}
|
|
13302
|
+
this.fill.onMainTokens(ev.text);
|
|
13303
|
+
return;
|
|
13304
|
+
case "tool":
|
|
13305
|
+
this.gate.replyStarted(c.id);
|
|
13306
|
+
void this.onTool(c, ev.name ?? "", ev.args);
|
|
13307
|
+
return;
|
|
13308
|
+
case "tool_done":
|
|
13309
|
+
return;
|
|
13310
|
+
case "audio":
|
|
13311
|
+
this.lastAudibleAt = this.o.now();
|
|
13312
|
+
this.fill.onAgentState("first_audio");
|
|
13313
|
+
this.sounded(c, "main");
|
|
13314
|
+
if (!c.worded) {
|
|
13315
|
+
c.worded = true;
|
|
13316
|
+
this.o.onMetric({ name: "first_word", id: c.id, ms: Math.round(this.o.now() - c.at), by: "main" });
|
|
13317
|
+
}
|
|
13318
|
+
return;
|
|
13319
|
+
case "done":
|
|
13320
|
+
case "error":
|
|
13321
|
+
c.running = false;
|
|
13322
|
+
this.clearProgress();
|
|
13323
|
+
this.fill.onAgentState("idle");
|
|
13324
|
+
return;
|
|
13325
|
+
}
|
|
13326
|
+
}
|
|
13327
|
+
/** The user barged in (hard): cut fill + MAIN audio (the host cuts its own), abort a running MAIN turn. */
|
|
13328
|
+
barge() {
|
|
13329
|
+
this.fill.onBargeIn();
|
|
13330
|
+
this.clearProgress();
|
|
13331
|
+
try {
|
|
13332
|
+
this.o.cancel();
|
|
13333
|
+
} catch (e) {
|
|
13334
|
+
log29.warn(`cancel threw: ${e.message}`);
|
|
13335
|
+
}
|
|
13336
|
+
if (this.cur?.running) {
|
|
13337
|
+
this.cur.running = false;
|
|
13338
|
+
this.o.abortTurn(this.cur.id);
|
|
13339
|
+
}
|
|
13340
|
+
}
|
|
13341
|
+
/** Soft barge-in: keep (a listener cue — no turn) or cut (→ barge()). */
|
|
13342
|
+
async overlap(text) {
|
|
13343
|
+
const d = await this.judge.judge(text);
|
|
13344
|
+
if (d.verdict === "cut") this.barge();
|
|
13345
|
+
return d.verdict;
|
|
13346
|
+
}
|
|
13347
|
+
/** Did the last barge cut OUR fill line `text`? (hosts strip it from the cut reply — it is not MAIN's speech) */
|
|
13348
|
+
spoke(text) {
|
|
13349
|
+
return this.fill.spoke(text);
|
|
13350
|
+
}
|
|
13351
|
+
/** Resolves when the in-flight fill line finished — hosts queue MAIN audio behind it (stack, don't cut). */
|
|
13352
|
+
drain() {
|
|
13353
|
+
return this.fill.drain();
|
|
13354
|
+
}
|
|
13355
|
+
end() {
|
|
13356
|
+
this.ended = true;
|
|
13357
|
+
this.gate.end();
|
|
13358
|
+
this.clearProgress();
|
|
13359
|
+
}
|
|
13360
|
+
// ---- internals ----
|
|
13361
|
+
/** Follow the user's language from their own words: a confident detection, else the current language if the words
|
|
13362
|
+
* fit it, else unknown (silent). Pinned into the channel so its own detection never diverges. */
|
|
13363
|
+
follow(text) {
|
|
13364
|
+
const l = detectLanguage(text) ?? (this.lang && fitsLanguage(text, this.lang) ? this.lang : null);
|
|
13365
|
+
if (l !== this.lang) this.o.onMetric({ name: "lang", lang: l, text });
|
|
13366
|
+
this.lang = l;
|
|
13367
|
+
if (l) this.fill.setLanguage(l, true);
|
|
13368
|
+
}
|
|
13369
|
+
onTurn(t) {
|
|
13370
|
+
const at = this.firstFinalAt ?? this.o.now();
|
|
13371
|
+
this.firstFinalAt = null;
|
|
13372
|
+
this.follow(t.text);
|
|
13373
|
+
this.clearProgress();
|
|
13374
|
+
this.cur = { id: t.id, text: t.text, at, running: true, streamed: false, sounded: false, worded: false };
|
|
13375
|
+
this.progress.turn();
|
|
13376
|
+
this.o.onMetric({ name: "turn", id: t.id, kind: t.kind, text: t.text });
|
|
13377
|
+
this.o.sendTurn(t.text, { id: t.id, ...t.replaces ? { replaces: t.replaces } : {} });
|
|
13378
|
+
if (t.kind === "new") this.fill.onUserTurn(t.text);
|
|
13379
|
+
}
|
|
13380
|
+
onDrop(text, why) {
|
|
13381
|
+
if (!this.gate.holding) this.firstFinalAt = null;
|
|
13382
|
+
this.o.onMetric({ name: "drop", kind: why, text });
|
|
13383
|
+
}
|
|
13384
|
+
/** Every fill/progress line passes here: only in the turn's known language, never after the call ended. */
|
|
13385
|
+
say(text, kind) {
|
|
13386
|
+
if (this.ended || !this.lang || this.fill.language !== this.lang) return false;
|
|
13387
|
+
let r;
|
|
13388
|
+
try {
|
|
13389
|
+
r = this.o.speak(text, kind);
|
|
13390
|
+
} catch (e) {
|
|
13391
|
+
log29.warn(`speak threw: ${e.message}`);
|
|
13392
|
+
return false;
|
|
13393
|
+
}
|
|
13394
|
+
if (r === false) return false;
|
|
13395
|
+
this.lastAudibleAt = this.o.now();
|
|
13396
|
+
this.o.onMetric({ name: "fill", kind, text, lang: this.lang });
|
|
13397
|
+
if (this.cur && (kind === "bridge" || kind === "progress")) this.sounded(this.cur, kind === "progress" ? "progress" : "fill");
|
|
13398
|
+
else if (this.cur && this.cur.running && !this.cur.sounded && kind === "filler") this.sounded(this.cur, "fill");
|
|
13399
|
+
return r;
|
|
13400
|
+
}
|
|
13401
|
+
sounded(c, by) {
|
|
13402
|
+
if (c.sounded) return;
|
|
13403
|
+
c.sounded = true;
|
|
13404
|
+
this.o.onMetric({ name: "first_sound", id: c.id, ms: Math.round(this.o.now() - c.at), by });
|
|
13405
|
+
}
|
|
13406
|
+
async onTool(c, name, args) {
|
|
13407
|
+
const lang = this.lang;
|
|
13408
|
+
if (!lang || c.streamed) return;
|
|
13409
|
+
const line = await this.progress.line(name, args, lang);
|
|
13410
|
+
if (!line || this.cur !== c || c.streamed || !c.running || this.lang !== lang) return;
|
|
13411
|
+
this.clearProgress();
|
|
13412
|
+
const tryNow = () => {
|
|
13413
|
+
this.progressTimer = null;
|
|
13414
|
+
if (this.ended || this.cur !== c || c.streamed || !c.running) return;
|
|
13415
|
+
if (!this.progress.due(this.lastAudibleAt)) {
|
|
13416
|
+
this.progressTimer = setTimeout(tryNow, 250);
|
|
13417
|
+
return;
|
|
13418
|
+
}
|
|
13419
|
+
if (this.say(line, "progress") !== false) {
|
|
13420
|
+
this.progress.spoke(line);
|
|
13421
|
+
this.o.show(line, "progress");
|
|
13422
|
+
}
|
|
13423
|
+
};
|
|
13424
|
+
tryNow();
|
|
13425
|
+
}
|
|
13426
|
+
clearProgress() {
|
|
13427
|
+
if (this.progressTimer) {
|
|
13428
|
+
clearTimeout(this.progressTimer);
|
|
13429
|
+
this.progressTimer = null;
|
|
13430
|
+
}
|
|
13431
|
+
}
|
|
13432
|
+
};
|
|
13433
|
+
|
|
13434
|
+
// src/index.ts
|
|
11824
13435
|
init_llm();
|
|
11825
13436
|
import { MemFilesystem as MemFilesystem3, IndexedDbFilesystem, CommandExecutor as CommandExecutor2, registerHeadlessCommands as registerHeadlessCommands2 } from "@livx.cc/wcli/core";
|
|
11826
13437
|
export {
|
|
@@ -11854,8 +13465,11 @@ export {
|
|
|
11854
13465
|
MountFilesystem,
|
|
11855
13466
|
NodeDiskFilesystem,
|
|
11856
13467
|
OverlayFilesystem,
|
|
13468
|
+
PROGRESS_TEMPLATES,
|
|
11857
13469
|
PermissionOptions,
|
|
11858
13470
|
PermissionPolicy,
|
|
13471
|
+
Progress,
|
|
13472
|
+
ProgressOptions,
|
|
11859
13473
|
RecordingHooks,
|
|
11860
13474
|
RecordingLifecycle,
|
|
11861
13475
|
SCRATCH_DIR,
|
|
@@ -11867,8 +13481,12 @@ export {
|
|
|
11867
13481
|
SonioxSTT,
|
|
11868
13482
|
SonioxSTTOptions,
|
|
11869
13483
|
TTS_SAMPLE_RATE,
|
|
13484
|
+
TurnGate,
|
|
13485
|
+
TurnGateOptions,
|
|
11870
13486
|
VOICE_MEMORY_PROMPT,
|
|
11871
13487
|
VOICE_SYSTEM_PROMPT,
|
|
13488
|
+
VoiceCall,
|
|
13489
|
+
VoiceCallOptions,
|
|
11872
13490
|
VoiceEngine,
|
|
11873
13491
|
VoiceEngineOptions,
|
|
11874
13492
|
applyEditsTool,
|
|
@@ -11892,6 +13510,7 @@ export {
|
|
|
11892
13510
|
elideImages,
|
|
11893
13511
|
elideInlineImages,
|
|
11894
13512
|
elideStaleImages,
|
|
13513
|
+
endsMidPhrase,
|
|
11895
13514
|
enterWorktree,
|
|
11896
13515
|
estimateTokens,
|
|
11897
13516
|
exitSessionTool,
|
|
@@ -11902,6 +13521,7 @@ export {
|
|
|
11902
13521
|
expandTemplate,
|
|
11903
13522
|
findGitRoot,
|
|
11904
13523
|
forComponent,
|
|
13524
|
+
forgetFact,
|
|
11905
13525
|
formatJobNotice,
|
|
11906
13526
|
fullAgentOptions,
|
|
11907
13527
|
getOrCreateWorktree,
|
|
@@ -11913,6 +13533,7 @@ export {
|
|
|
11913
13533
|
imagePart,
|
|
11914
13534
|
imageRefPart,
|
|
11915
13535
|
imageRefResult,
|
|
13536
|
+
isClosing,
|
|
11916
13537
|
jevAvailable,
|
|
11917
13538
|
jevClassifier,
|
|
11918
13539
|
latestClaude,
|