@livx.cc/agentx 0.99.91 → 0.99.93
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +421 -74
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +33 -4
- package/dist/index.js +274 -36
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -3778,6 +3778,38 @@ async function loadMemory(fs, dir, opts = {}) {
|
|
|
3778
3778
|
- (${restSlugs.length} more learnings, slugs only \u2014 call \`Recall\` if relevant): ${restSlugs.join(", ")}` : "");
|
|
3779
3779
|
return { index, tools };
|
|
3780
3780
|
}
|
|
3781
|
+
var TERM_STOPWORDS = new Set(`the and for but not you your yours our ours his her hers its their they them this that these those
|
|
3782
|
+
there here what when where which who whom whose why how all any each every some many much more most few other such only own same
|
|
3783
|
+
also just very can will would should could may might must shall have has had does did done was were are been being use used using
|
|
3784
|
+
always never often sometimes usually once then than now today tomorrow yesterday before after later soon first last next new old
|
|
3785
|
+
read write see make take give get set keep run add note notes user users company name names project projects team work works
|
|
3786
|
+
working home inbox label labels file files code prefer prefers please don't doesn't via per with without from into about over
|
|
3787
|
+
under yes okay important example one two three monday tuesday wednesday thursday friday saturday sunday`.split(/\s+/));
|
|
3788
|
+
var SECRET_RE = /\b(code\s*word|pass(word|phrase|code)?|token|secret|api[\s_-]*key|key|pin|credentials?|otp|2fa|private)\b/i;
|
|
3789
|
+
async function memoryTerms(fs, dir, extra = [], max = 30) {
|
|
3790
|
+
const out = /* @__PURE__ */ new Map();
|
|
3791
|
+
const add = (t) => {
|
|
3792
|
+
const k = t.toLowerCase();
|
|
3793
|
+
if (t.length > 2 && !TERM_STOPWORDS.has(k) && !out.has(k)) out.set(k, t);
|
|
3794
|
+
};
|
|
3795
|
+
extra.filter(Boolean).forEach(add);
|
|
3796
|
+
const bodies = { user: [], other: [] };
|
|
3797
|
+
for (const d of (Array.isArray(dir) ? dir : [dir]).filter(Boolean)) {
|
|
3798
|
+
if (!await fs.exists(`${d}/MEMORY.md`)) continue;
|
|
3799
|
+
const slugs = [...(await fs.readFile(`${d}/MEMORY.md`)).matchAll(/\]\(([^)/]+)\.md\)/g)].map((m) => m[1]);
|
|
3800
|
+
for (const slug2 of slugs) {
|
|
3801
|
+
const path = `${d}/${slug2}.md`;
|
|
3802
|
+
if (!await fs.exists(path)) continue;
|
|
3803
|
+
const { block, body } = splitFrontmatter(await fs.readFile(path));
|
|
3804
|
+
const type = grabField(block, "type");
|
|
3805
|
+
if (!["user", "project", "reference"].includes(type ?? "") || SECRET_RE.test(body)) continue;
|
|
3806
|
+
bodies[type === "user" ? "user" : "other"].push(body);
|
|
3807
|
+
}
|
|
3808
|
+
}
|
|
3809
|
+
for (const body of [...bodies.user, ...bodies.other])
|
|
3810
|
+
for (const m of body.matchAll(/\b(?:[A-Z][a-z]+[A-Za-z]*|[A-Z]{3,})\b/g)) add(m[0]);
|
|
3811
|
+
return [...out.values()].slice(0, max);
|
|
3812
|
+
}
|
|
3781
3813
|
function slugify(s, fallback = "note") {
|
|
3782
3814
|
const base = String(s ?? "").trim().toLowerCase().replace(/\.md$/i, "").replace(/[^\w\s-]/g, "").replace(/[\s_]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "").slice(0, 48);
|
|
3783
3815
|
return base || fallback;
|
|
@@ -9670,6 +9702,19 @@ async function judgeLeak(decider, sentence, userSaid) {
|
|
|
9670
9702
|
if (!Number.isFinite(p)) throw new Error("leak judge: no noul in the answer");
|
|
9671
9703
|
return p;
|
|
9672
9704
|
}
|
|
9705
|
+
function scrubSpokenJunk(text, final = false) {
|
|
9706
|
+
let s = text.replace(/[-]/g, "").replace(/\[\s*emotion\b([^[\]]*)\]/gi, (_m, inner) => {
|
|
9707
|
+
const e = normalizeEmotion(inner.trim());
|
|
9708
|
+
return e ? `[${e}]` : "";
|
|
9709
|
+
}).replace(/\[[^\p{L}\p{N}[\]]*\]/gu, "").replace(/(?:[\s ]*(?:…|\.{2,}|[—–]|-{2,})){3,}/g, (m) => /^[\s ]/.test(m) ? " \u2026" : "\u2026");
|
|
9710
|
+
if (final) s = s.replace(/\[\s*emotion\b[^\]]*$/i, "");
|
|
9711
|
+
return s;
|
|
9712
|
+
}
|
|
9713
|
+
function isRepeatOf(sentence, spoken) {
|
|
9714
|
+
const norm3 = (x) => x.replace(/\[[a-z_ ]+\]/gi, "").replace(/[^\p{L}\p{N}]+/gu, " ").trim().toLowerCase();
|
|
9715
|
+
const s = norm3(sentence);
|
|
9716
|
+
return s.replace(/ /g, "").length >= 16 && spoken.split(SENTENCE_SPLIT).some((x) => norm3(x) === s);
|
|
9717
|
+
}
|
|
9673
9718
|
|
|
9674
9719
|
// src/duplex.ts
|
|
9675
9720
|
var log21 = forComponent("DuplexAgent");
|
|
@@ -9712,6 +9757,10 @@ var DuplexAgentOptions = class {
|
|
|
9712
9757
|
host;
|
|
9713
9758
|
/** How many recent transcript messages are rendered into a worker's brief. */
|
|
9714
9759
|
excerptTurns = 6;
|
|
9760
|
+
/** A Hold turn whose user says nothing more for this long is answered after all (a re-voice turn told they stopped):
|
|
9761
|
+
* without it a wrong Hold left the turn as a lone "mm-hm" forever. 0 = off. Only for a host that reports the user's
|
|
9762
|
+
* live speech via userActive() — else a slow talker's next words would be spoken over. */
|
|
9763
|
+
holdResumeMs = 0;
|
|
9715
9764
|
/** Voice register: 'neutral' = clean spoken style; 'conversational' = human-like — fillers,
|
|
9716
9765
|
* backchannels, impulsive first reactions before content (mimics real duplex conversation). */
|
|
9717
9766
|
voiceStyle = "neutral";
|
|
@@ -9752,6 +9801,9 @@ var DuplexAgentOptions = class {
|
|
|
9752
9801
|
/** Host overrides for QuickLook lookups (keyed by `what`). The engine's defaults go through the
|
|
9753
9802
|
* (possibly jailed) fs — e.g. `.git/**` is deny-listed, so the CLI supplies 'branch' itself. */
|
|
9754
9803
|
quickLook;
|
|
9804
|
+
/** Extra roots QuickLook ls/file try for a relative path not found under the cwd (the CLI passes ~ and ~/Documents:
|
|
9805
|
+
* a spoken "Projects/X" means ~/Documents/Projects/X far more often than <cwd>/Projects/X). */
|
|
9806
|
+
lookRoots = [];
|
|
9755
9807
|
/** Memory directory/directories on the WORKER fs. If set, the voice agent gets Remember + Recall
|
|
9756
9808
|
* tools directly (no delegation needed) and implicit capture guidance. */
|
|
9757
9809
|
memoryDir;
|
|
@@ -9759,6 +9811,60 @@ var DuplexAgentOptions = class {
|
|
|
9759
9811
|
memoryUserDir;
|
|
9760
9812
|
};
|
|
9761
9813
|
var SESSION_ID_RE = /^\d{8}-\d{6}(?:-[\w-]+)?$/;
|
|
9814
|
+
var MID_PHRASE_WORDS = /* @__PURE__ */ new Set([
|
|
9815
|
+
"slash",
|
|
9816
|
+
"backslash",
|
|
9817
|
+
"dash",
|
|
9818
|
+
"hyphen",
|
|
9819
|
+
"dot",
|
|
9820
|
+
"underscore",
|
|
9821
|
+
"colon",
|
|
9822
|
+
"comma",
|
|
9823
|
+
"and",
|
|
9824
|
+
"or",
|
|
9825
|
+
"but",
|
|
9826
|
+
"so",
|
|
9827
|
+
"because",
|
|
9828
|
+
"if",
|
|
9829
|
+
"then",
|
|
9830
|
+
"called",
|
|
9831
|
+
"named",
|
|
9832
|
+
"the",
|
|
9833
|
+
"a",
|
|
9834
|
+
"an",
|
|
9835
|
+
"my",
|
|
9836
|
+
"your",
|
|
9837
|
+
"our",
|
|
9838
|
+
"their",
|
|
9839
|
+
"is",
|
|
9840
|
+
"are",
|
|
9841
|
+
"um",
|
|
9842
|
+
"uh"
|
|
9843
|
+
]);
|
|
9844
|
+
var TRAILING_PREPS = /* @__PURE__ */ new Set(["to", "for", "with", "about", "of", "at", "in", "on", "from", "by", "into", "under", "inside"]);
|
|
9845
|
+
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;
|
|
9846
|
+
var PHRASAL_HOST = /* @__PURE__ */ new Set(["it", "this", "that", "them", "him", "her", "me", "us", "you", "up", "out", "off", "down", "back", "over", "away"]);
|
|
9847
|
+
function endsMidPhrase(text) {
|
|
9848
|
+
const t = text.split("\n[")[0].trim();
|
|
9849
|
+
if (!t || /[.?!…]["')\]]*$/.test(t)) return false;
|
|
9850
|
+
if (/\b[a-z][\w+.-]*:\/\/\S+$/i.test(t)) return false;
|
|
9851
|
+
if (/[,/\\:\-–—]$/.test(t)) return true;
|
|
9852
|
+
const [last, prev] = (t.toLowerCase().match(/[\p{L}']+/gu) ?? []).reverse();
|
|
9853
|
+
if (!last) return false;
|
|
9854
|
+
if (TRAILING_PREPS.has(last)) return !QUESTION_START.test(t) && !PHRASAL_HOST.has(prev);
|
|
9855
|
+
return MID_PHRASE_WORDS.has(last);
|
|
9856
|
+
}
|
|
9857
|
+
function nameScore(want, have) {
|
|
9858
|
+
const a = want.toLowerCase().replace(/[^\p{L}\p{N}]/gu, ""), b = have.toLowerCase().replace(/[^\p{L}\p{N}]/gu, "");
|
|
9859
|
+
if (!a || !b) return 0;
|
|
9860
|
+
let pre = 0;
|
|
9861
|
+
while (pre < a.length && a[pre] === b[pre]) pre++;
|
|
9862
|
+
const grams = (x) => new Set(Array.from({ length: Math.max(0, x.length - 1) }, (_, i) => x.slice(i, i + 2)));
|
|
9863
|
+
const ga = grams(a), gb = grams(b);
|
|
9864
|
+
let common = 0;
|
|
9865
|
+
for (const g of ga) if (gb.has(g)) common++;
|
|
9866
|
+
return pre * 2 + 2 * common / (ga.size + gb.size || 1);
|
|
9867
|
+
}
|
|
9762
9868
|
var RESERVED_EVENT_MARKER = /\[task\b[^\]\n]*\b(?:completed|failed|progress|asks)\b/i;
|
|
9763
9869
|
var RESERVED_EVENT_OPENER = /\[\s*task\b/i;
|
|
9764
9870
|
var STAGE_DIRECTION_RE = /^\(\s*(?:(?:waiting|checking|searching|thinking|processing|loading|working|fetching|looking)\b[^)]*|[^)]*(?:\.\.\.|…)\s*)\)$/i;
|
|
@@ -9781,11 +9887,11 @@ function isOfferAcceptance(text) {
|
|
|
9781
9887
|
}
|
|
9782
9888
|
var isQuestion = (s) => /\?["'”’)\]]*$/.test(s.trim());
|
|
9783
9889
|
var OFFER_OVERRUN = 150;
|
|
9784
|
-
var VOICE_SYSTEM_PROMPT = 'You are a spoken voice assistant \u2014 the user HEARS everything you say. Use short sentences. One idea per sentence. No markdown, no bullet lists, no code blocks, no headings, no emoji. Never emit stage directions or parenthetical asides about your own process \u2014 nothing like "(waiting for the result...)" or "(checking)"; while work runs, either say it as plain speech or end your turn.\nThis holds even when asked to "print", "list", "show", or "make a table" \u2014 there is no screen for the spoken channel. Speak it as flowing prose ("Tuesday is half a meter, Wednesday a bit less\u2026"), or if they truly need it on screen, route it to Act to render. Never emit dashes or pipes into speech.\nKeep turns SHORT \u2014 one to three sentences, then stop. Never lecture, enumerate cases, or add caveats unprompted. Conversation is a fast exchange: give the one thing asked, and let the user pull more if they want it.\nYou have three cognitive tiers \u2014 like a human brain:\n\u2022 YOU (reflex) \u2014 instant, lightweight. Handle greetings, simple questions, status checks, QuickLook.\n\u2022 `Act` \u2014 your hands. A background worker with its own configured tools and access to the user\'s environment (files and shell{{WORKER_WEB}}). Use for reading, editing, searching, running tasks, building \u2014 any real work.\n{{THINK_SLOT}}\nWhen you are unsure whether you can do or access something, do NOT assume and do NOT claim a capability you have not confirmed. To check what you can do, QuickLook `capabilities` (instant \u2014 it lists your worker\'s real tools) and answer from that. Never promise an ability that is not in your capabilities; if it is not there, tell the user plainly you can\'t. To actually DO real work, call `Act`. When the user mentions their project, folder, files, or environment ("this project", "the current folder", "my code"), call `Act` IMMEDIATELY \u2014 do not ask for paths or details the worker can discover itself. Never pretend to have done the work or invent results \u2014 the worker\'s report is your only source.\nYou cannot mute the microphone or stop voice capture yourself \u2014 no tool does it. If the user asks you to stop listening or turn the voice off, never claim you did: tell them to say exactly "voice off" (handled by the app directly), or type /voice.\nYou are NOT a knowledge base. For any question whose answer needs SPECIFIC verifiable facts you do not already have in hand \u2014 how to build/configure/implement something, exact API, library, entitlement, command or option names, current events, or particular numbers, dates, or names \u2014 do NOT answer from your own memory: you will confidently make things up (a fake API, a wrong entitlement, an event that did not happen). Route it to `Act`, which can search and verify, and speak only what its report says. DELEGATION RULE \u2014 decide for yourself, the user never has to push: if you cannot answer confidently from the conversation plus trivial well-known knowledge, do NOT refuse and do NOT guess \u2014 dispatch `Act` immediately with a clear brief and say you are checking. Anything needing CURRENT data (weather, news, prices, dates, sky/astronomy, "right now"), real computation, or verification is an automatic dispatch \u2014 never a refusal. The user should never need to say "search the web" or "think harder" to make you act; needing fresh or verified information IS the trigger. Refuse only what your worker genuinely cannot do (check `capabilities`), and say why. Answer inline ONLY for general conversation, chit-chat, and trivia you are sure of, or facts you can see via QuickLook. When elaborating on a completed task ("tell me more", "the gist"), stay strictly within what that result actually said \u2014 if the user asks for something the result did not cover, that is NEW information: dispatch `Act`, do not improvise.\nALWAYS react before you work: the FIRST thing in your turn is a brief spoken acknowledgement of what you heard and what you are about to do ("got it \u2014 opening that now", "sure, let me pull it up", "okay, checking"). NEVER call a tool (Act, Think, QuickLook) silently \u2014 the user must hear you react before you go quiet to work. After dispatching Act or Think, that same one short sentence IS your turn \u2014 end it and do not wait for the result. Exactly ONE short line: never stack a second acknowledgement, and never narrate your own presence or status while waiting ("I\'m here", "let me see", "still checking" right after you already acked). One clean ack then silence reads as competent; repeated check-ins read as nervous.\nA completed task speaks its OWN result to the user (the worker voices what matters as it finishes) \u2014 you do NOT re-voice clean task results. A FAILED or INCOMPLETE task still arrives as a "[task t1 failed] \u2026" event for you to handle. The completed result stays in YOUR context \u2014 it is yours to draw on. When the user follows up ("tell me more", "what else", "and?"), answer FROM that result first: you already have the detail, so elaborate on what you have. Do NOT spawn a fresh worker to re-search or re-gather what you were just handed. Re-dispatch ONLY when genuinely new information is needed \u2014 e.g. the user wants the full contents of a SPECIFIC source, which is one WebFetch of that URL, not a brand-new search. "[task t1 progress] \u2026" events are interim status, NOT results \u2014 on a genuinely LONG wait you MAY give one brief half-sentence aside, but only occasionally; silence while working is normal and fine (the user knows you are on it). Do NOT narrate every step or re-announce yourself. Never present progress as a finished result.\nCRITICAL: while a task is still running you have NO answer yet \u2014 never state a specific result of any kind (a number, size, count, name, path, or value). The real answer exists ONLY once the task finishes (the worker speaks it, and `TaskStatus` then returns it); inventing one meanwhile (a made-up disk size, commit count, etc.) is a serious error. Until then, only acknowledge and wait.\nNever read raw file paths, diffs, or code aloud verbatim.\nDo NOT end every turn with the same canned offer ("want a rundown?", "want the steps?"). Offer once at most; if the user pushes back, repeats themselves, or sounds unsatisfied ("you know what I mean?", "think deeper", "are you sure?"), do NOT re-offer the same thing \u2014 change approach: dispatch `Act`/`Think` to actually dig in, or ask one concrete clarifying question. Repeating a non-answer is worse than silence.\n"[task t1 asks] \u2026" events are QUESTIONS from a background task \u2014 relay to the user in your own words, short, then end your turn. When the user answers, call `AnswerTask` with that id and their answer. NEVER answer on the user\'s behalf for permissions or risky operations; if their reply is ambiguous, confirm first.\nIf the user\'s message sounds INCOMPLETE \u2014 trailing off mid-sentence, a fragment that needs more context ("and then we", "but the problem is"), hesitation fillers ("uh", "um") \u2014 call `Hold` instead of answering. This keeps listening for the rest of their thought. Only respond with substance when you have a complete question or request.\nDispatch discipline: send ONE self-contained task per request \u2014 a single worker with the full brief beats several workers with fragments (each worker starts fresh and re-discovers context). NEVER dispatch a worker just to read files or gather information \u2014 workers explore and discover context themselves; pass on what you already know and let one worker do the whole job. Split into parallel tasks only when the user asks for genuinely independent things. When a task completes, report its result and stop \u2014 do NOT dispatch follow-up work (verification, polish, extras) the user did not ask for, unless the report itself signals failure or doubt.\nDo not fire a second Act/Think for work already in flight, and NEVER spawn a second task to re-count, cross-check, or verify a result a worker already gave you \u2014 trust its answer; a single question gets ONE task. Call `TaskStatus` at most ONCE per turn; if a task is still running, give one brief status aside and end the turn \u2014 never poll it again and again in a loop. "Still on it" is ONLY for a repeat check on a task you already acknowledged earlier \u2014 NEVER as the ack when you first dispatch work ("still" is wrong when nothing was running yet; use a fresh "on it"/"got it, checking" instead). Use `CancelTask` when the user asks to stop something.\nPRIORITY: when the user says goodbye or wants to end/finish/wrap up the session ("ok bye", "that\'s all", "let\'s finish", "let\'s end", "goodnight", "exit", "wrap up"), call `ExitSession` IMMEDIATELY \u2014 do not act, do not check status, just exit.\nFor TRIVIAL instant lookups only \u2014 current time, git branch, listing a folder, peeking at a small file, or checking your own `capabilities`/tools \u2014 use `QuickLook` (instant, no task). Whenever the user asks what you can do or whether you have some ability, QuickLook `capabilities` and answer from that \u2014 never guess. Anything requiring searching, reasoning, running commands, or editing goes through `Act`.\n{{MEMORY_SLOT}}\nUser messages may arrive via speech-to-text and can carry transcription artifacts \u2014 odd words, cut-offs, homophones ("for you" vs "folder"). Read for INTENT, not surface text. If a message seems garbled, surprising, or only half-parses, do NOT guess an action or improvise content from it \u2014 briefly confirm what they meant ("did you mean\u2026?") and wait. A one-line confirm beats a confident wrong answer or an invented response to a request you did not actually understand.';
|
|
9890
|
+
var VOICE_SYSTEM_PROMPT = 'You are a spoken voice assistant \u2014 the user HEARS everything you say. Use short sentences. One idea per sentence. No markdown, no bullet lists, no code blocks, no headings, no emoji. Never emit stage directions or parenthetical asides about your own process \u2014 nothing like "(waiting for the result...)" or "(checking)"; while work runs, either say it as plain speech or end your turn.\nThis holds even when asked to "print", "list", "show", or "make a table" \u2014 there is no screen for the spoken channel. Speak it as flowing prose ("Tuesday is half a meter, Wednesday a bit less\u2026"), or if they truly need it on screen, route it to Act to render. Never emit dashes or pipes into speech.\nKeep turns SHORT \u2014 one to three sentences, then stop. Never lecture, enumerate cases, or add caveats unprompted. Conversation is a fast exchange: give the one thing asked, and let the user pull more if they want it.\nYou have three cognitive tiers \u2014 like a human brain:\n\u2022 YOU (reflex) \u2014 instant, lightweight. Handle greetings, simple questions, status checks, QuickLook.\n\u2022 `Act` \u2014 your hands. A background worker with its own configured tools and access to the user\'s environment (files and shell{{WORKER_WEB}}). Use for reading, editing, searching, running tasks, building \u2014 any real work.\n{{THINK_SLOT}}\nWhen you are unsure whether you can do or access something, do NOT assume and do NOT claim a capability you have not confirmed. To check what you can do, QuickLook `capabilities` (instant \u2014 it lists your worker\'s real tools) and answer from that. Never promise an ability that is not in your capabilities; if it is not there, tell the user plainly you can\'t. To actually DO real work, call `Act`. When the user mentions their project, folder, files, or environment ("this project", "the current folder", "my code"), call `Act` IMMEDIATELY \u2014 do not ask for paths or details the worker can discover itself. Never pretend to have done the work or invent results \u2014 the worker\'s report is your only source.\nYou cannot mute the microphone or stop voice capture yourself \u2014 no tool does it. If the user asks you to stop listening or turn the voice off, never claim you did: tell them to say exactly "voice off" (handled by the app directly), or type /voice.\nYou are NOT a knowledge base. For any question whose answer needs SPECIFIC verifiable facts you do not already have in hand \u2014 how to build/configure/implement something, exact API, library, entitlement, command or option names, current events, or particular numbers, dates, or names \u2014 do NOT answer from your own memory: you will confidently make things up (a fake API, a wrong entitlement, an event that did not happen). Route it to `Act`, which can search and verify, and speak only what its report says. DELEGATION RULE \u2014 decide for yourself, the user never has to push: if you cannot answer confidently from the conversation plus trivial well-known knowledge, do NOT refuse and do NOT guess \u2014 dispatch `Act` immediately with a clear brief and say you are checking. Anything needing CURRENT data (weather, news, prices, dates, sky/astronomy, "right now"), real computation, or verification is an automatic dispatch \u2014 never a refusal. The user should never need to say "search the web" or "think harder" to make you act; needing fresh or verified information IS the trigger. Refuse only what your worker genuinely cannot do (check `capabilities`), and say why. Answer inline ONLY for general conversation, chit-chat, and trivia you are sure of, or facts you can see via QuickLook. When elaborating on a completed task ("tell me more", "the gist"), stay strictly within what that result actually said \u2014 if the user asks for something the result did not cover, that is NEW information: dispatch `Act`, do not improvise.\nALWAYS react before you work: the FIRST thing in your turn is a brief spoken acknowledgement of what you heard and what you are about to do ("got it \u2014 opening that now", "sure, let me pull it up", "okay, checking"). NEVER call a tool (Act, Think, QuickLook) silently \u2014 the user must hear you react before you go quiet to work. After dispatching Act or Think, that same one short sentence IS your turn \u2014 end it and do not wait for the result. Exactly ONE short line: never stack a second acknowledgement, and never narrate your own presence or status while waiting ("I\'m here", "let me see", "still checking" right after you already acked). One clean ack then silence reads as competent; repeated check-ins read as nervous.\nA completed task speaks its OWN result to the user (the worker voices what matters as it finishes) \u2014 you do NOT re-voice clean task results. A FAILED or INCOMPLETE task still arrives as a "[task t1 failed] \u2026" event for you to handle. The completed result stays in YOUR context \u2014 it is yours to draw on. When the user follows up ("tell me more", "what else", "and?"), answer FROM that result first: you already have the detail, so elaborate on what you have. Do NOT spawn a fresh worker to re-search or re-gather what you were just handed. Re-dispatch ONLY when genuinely new information is needed \u2014 e.g. the user wants the full contents of a SPECIFIC source, which is one WebFetch of that URL, not a brand-new search. "[task t1 progress] \u2026" events are interim status, NOT results \u2014 on a genuinely LONG wait you MAY give one brief half-sentence aside, but only occasionally; silence while working is normal and fine (the user knows you are on it). Do NOT narrate every step or re-announce yourself. Never present progress as a finished result.\nCRITICAL: while a task is still running you have NO answer yet \u2014 never state a specific result of any kind (a number, size, count, name, path, or value). The real answer exists ONLY once the task finishes (the worker speaks it, and `TaskStatus` then returns it); inventing one meanwhile (a made-up disk size, commit count, etc.) is a serious error. Until then, only acknowledge and wait.\nNever read raw file paths, diffs, or code aloud verbatim.\nDo NOT end every turn with the same canned offer ("want a rundown?", "want the steps?"). Offer once at most; if the user pushes back, repeats themselves, or sounds unsatisfied ("you know what I mean?", "think deeper", "are you sure?"), do NOT re-offer the same thing \u2014 change approach: dispatch `Act`/`Think` to actually dig in, or ask one concrete clarifying question. Repeating a non-answer is worse than silence.\n"[task t1 asks] \u2026" events are QUESTIONS from a background task \u2014 relay to the user in your own words, short, then end your turn. When the user answers, call `AnswerTask` with that id and their answer. NEVER answer on the user\'s behalf for permissions or risky operations; if their reply is ambiguous, confirm first.\nIf the user\'s message sounds INCOMPLETE \u2014 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.';
|
|
9785
9891
|
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.";
|
|
9786
9892
|
var THINK_DISABLED_GUIDANCE = "(Think tier is not available \u2014 use Act for all escalations.)";
|
|
9787
9893
|
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).`;
|
|
9788
|
-
var EMOTION_TAGS_GUIDANCE = `EMOTION: your voice is synthesized with emotion control. Prefix a sentence with an inline
|
|
9894
|
+
var EMOTION_TAGS_GUIDANCE = `EMOTION: your voice is synthesized with emotion control. Prefix a sentence with an inline emotion tag \u2014 ONE emotion word from the list below in square brackets, the word alone (never the word "emotion" itself, never a guess or a question mark), placed directly before the sentence it colors, to shape how it is spoken. Use it ONLY when the emotion genuinely fits the words (it amplifies real feeling, it cannot fake it) \u2014 do not tag every sentence; reserve it for moments that carry feeling, and vary which one you use. You may also drop [laughter] for a natural laugh. Available emotions: ${EMOTIONS.join(", ")}.`;
|
|
9789
9895
|
var DuplexAgent = class _DuplexAgent {
|
|
9790
9896
|
options;
|
|
9791
9897
|
voice;
|
|
@@ -9836,6 +9942,10 @@ var DuplexAgent = class _DuplexAgent {
|
|
|
9836
9942
|
// host spoke on our behalf (adaptive micro-ack) — not reflex output
|
|
9837
9943
|
heldThisTurn = false;
|
|
9838
9944
|
// Hold called this turn → turn is INTENTIONALLY silent (suppress reflex text + no dead-air ack)
|
|
9945
|
+
holdTimer = null;
|
|
9946
|
+
// armed by a Hold turn's end (holdResumeMs)
|
|
9947
|
+
holdResumed = false;
|
|
9948
|
+
// the hold expired: the resume turn must answer (Hold refused) — until the user speaks
|
|
9839
9949
|
nudging = false;
|
|
9840
9950
|
// re-ack pass in flight: block ALL tools, prevent recursion
|
|
9841
9951
|
reflexBuf = "";
|
|
@@ -9846,6 +9956,10 @@ var DuplexAgent = class _DuplexAgent {
|
|
|
9846
9956
|
// reflex emitted a reserved [task …] marker → suppress its tail
|
|
9847
9957
|
leakCuts = [];
|
|
9848
9958
|
// heads of analysis-register leaks cut this turn — scrubbed from the transcript at turn end
|
|
9959
|
+
midPhraseBounced = false;
|
|
9960
|
+
// this turn's dispatch on a mid-phrase fragment was already bounced to Hold
|
|
9961
|
+
repeatCuts = [];
|
|
9962
|
+
// same-turn repeated sentences skipped this turn — their LATER copy is scrubbed from the transcript
|
|
9849
9963
|
leakStep = false;
|
|
9850
9964
|
// the CURRENT step's tail is muted by a leak cut (the next step speaks again, unlike a fabricated marker)
|
|
9851
9965
|
leakHit = false;
|
|
@@ -10009,7 +10123,7 @@ ${nowLine()}. Anchor every relative or time-sensitive reference \u2014 "today",
|
|
|
10009
10123
|
pumpReflex(final) {
|
|
10010
10124
|
for (; ; ) {
|
|
10011
10125
|
if (this.leakStep) return;
|
|
10012
|
-
this.
|
|
10126
|
+
this.scrubUnforwarded(final);
|
|
10013
10127
|
const from = this.reflexForwarded;
|
|
10014
10128
|
const buf = this.reflexBuf;
|
|
10015
10129
|
const lastMark = Math.max(from, ...this.stepMarks);
|
|
@@ -10019,6 +10133,8 @@ ${nowLine()}. Anchor every relative or time-sensitive reference \u2014 "today",
|
|
|
10019
10133
|
if (tail) upto -= tail[0].length;
|
|
10020
10134
|
const paren = buf.lastIndexOf("(");
|
|
10021
10135
|
if (paren >= lastMark && !buf.includes(")", paren) && buf.length - paren <= 80) upto = Math.min(upto, paren);
|
|
10136
|
+
const brk = buf.lastIndexOf("[");
|
|
10137
|
+
if (brk >= lastMark && !buf.includes("]", brk) && buf.length - brk <= 60) upto = Math.min(upto, brk);
|
|
10022
10138
|
}
|
|
10023
10139
|
if (upto <= from) return;
|
|
10024
10140
|
const region = buf.slice(from, upto);
|
|
@@ -10026,9 +10142,14 @@ ${nowLine()}. Anchor every relative or time-sensitive reference \u2014 "today",
|
|
|
10026
10142
|
for (const k of this.stepMarks) if (k > from && k <= upto) ends.push(k - from);
|
|
10027
10143
|
if (final || !ends.length && region.length >= 160) ends.push(region.length);
|
|
10028
10144
|
ends.sort((a, b) => a - b);
|
|
10029
|
-
let ok = 0, leak;
|
|
10145
|
+
let ok = 0, leak, repeat;
|
|
10030
10146
|
for (const e of ends) {
|
|
10031
10147
|
if (e <= ok) continue;
|
|
10148
|
+
const sent = region.slice(ok, e);
|
|
10149
|
+
if (sent.trim() && !/[\p{L}\p{N}]/u.test(sent) || isRepeatOf(sent, this.turnSpoken + region.slice(0, ok))) {
|
|
10150
|
+
repeat = e;
|
|
10151
|
+
break;
|
|
10152
|
+
}
|
|
10032
10153
|
const v = this.leakVerdict(region.slice(ok, e));
|
|
10033
10154
|
if (v === void 0) break;
|
|
10034
10155
|
if (v) {
|
|
@@ -10038,6 +10159,15 @@ ${nowLine()}. Anchor every relative or time-sensitive reference \u2014 "today",
|
|
|
10038
10159
|
ok = e;
|
|
10039
10160
|
}
|
|
10040
10161
|
this.forwardReflex(from + ok);
|
|
10162
|
+
if (repeat !== void 0) {
|
|
10163
|
+
const cut = region.slice(ok, repeat).trim();
|
|
10164
|
+
if (/[\p{L}\p{N}]/u.test(cut)) {
|
|
10165
|
+
this.repeatCuts.push(cut);
|
|
10166
|
+
this.notify("diag", "reflex_repeat", { text: cut.slice(0, 80) });
|
|
10167
|
+
}
|
|
10168
|
+
this.reflexForwarded = from + repeat;
|
|
10169
|
+
continue;
|
|
10170
|
+
}
|
|
10041
10171
|
if (leak === void 0) return;
|
|
10042
10172
|
const at = from + ok;
|
|
10043
10173
|
this.leakHit = true;
|
|
@@ -10112,6 +10242,22 @@ ${nowLine()}. Anchor every relative or time-sensitive reference \u2014 "today",
|
|
|
10112
10242
|
scrubLeakFromTranscript() {
|
|
10113
10243
|
const keys = this.leakCuts.splice(0);
|
|
10114
10244
|
const t = this.voice.transcript;
|
|
10245
|
+
for (let i = this.turnBase; i < t.length; i++) {
|
|
10246
|
+
const m = t[i];
|
|
10247
|
+
if (m.role === "assistant" && typeof m.content === "string") m.content = scrubSpokenJunk(m.content, true).trim();
|
|
10248
|
+
}
|
|
10249
|
+
for (const cut of this.repeatCuts.splice(0)) {
|
|
10250
|
+
const rep = cut.replace(/^(?:\[[^\]]*\]\s*)+/, "");
|
|
10251
|
+
if (!rep) continue;
|
|
10252
|
+
for (let i = t.length - 1; i >= this.turnBase; i--) {
|
|
10253
|
+
const m = t[i];
|
|
10254
|
+
if (m.role !== "assistant" || typeof m.content !== "string") continue;
|
|
10255
|
+
const at = m.content.lastIndexOf(rep);
|
|
10256
|
+
if (at <= 0 && !(at === 0 && t.slice(this.turnBase, i).some((x) => x.role === "assistant" && contentText(x.content).includes(rep)))) continue;
|
|
10257
|
+
m.content = (m.content.slice(0, at).replace(/\[[^\]]*\]\s*$/, "") + m.content.slice(at + rep.length)).trim();
|
|
10258
|
+
break;
|
|
10259
|
+
}
|
|
10260
|
+
}
|
|
10115
10261
|
for (const key of keys) {
|
|
10116
10262
|
let i = t.length - 1;
|
|
10117
10263
|
while (i >= this.turnBase && !(t[i].role === "assistant" && typeof t[i].content === "string" && t[i].content.includes(key))) i--;
|
|
@@ -10155,17 +10301,32 @@ ${nowLine()}. Anchor every relative or time-sensitive reference \u2014 "today",
|
|
|
10155
10301
|
await this.drainReflex();
|
|
10156
10302
|
this.scrubLeakFromTranscript();
|
|
10157
10303
|
this.scrubHeldText();
|
|
10304
|
+
if (this.heldThisTurn) {
|
|
10305
|
+
this.armHoldResume();
|
|
10306
|
+
return;
|
|
10307
|
+
}
|
|
10158
10308
|
if (this.silentTurn && res?.finishReason === "error" && refusedCall(res.error)) this.emitHost({ kind: "text_delta", message: fallback ?? _DuplexAgent.FALLBACK_ERROR });
|
|
10159
10309
|
else if (this.silentTurn) await this.ackIfSilent(fallback);
|
|
10160
10310
|
}
|
|
10161
|
-
/**
|
|
10162
|
-
* Only the unforwarded region is touched — already-spoken audio can't be unsent, and
|
|
10163
|
-
* reflexForwarded would corrupt the forward offset.
|
|
10164
|
-
|
|
10165
|
-
|
|
10166
|
-
const
|
|
10167
|
-
|
|
10168
|
-
|
|
10311
|
+
/** Rewrite the UNFORWARDED reflex text: drop complete stage-direction parentheticals (STAGE_DIRECTION_RE) and spoken
|
|
10312
|
+
* junk (scrubSpokenJunk). Only the unforwarded region is touched — already-spoken audio can't be unsent, and
|
|
10313
|
+
* splicing before reflexForwarded would corrupt the forward offset. Rewritten per step segment so the stepMarks
|
|
10314
|
+
* after it move with the text (a mark is a reflexBuf offset); a segment that ends at a step mark is complete. */
|
|
10315
|
+
scrubUnforwarded(final) {
|
|
10316
|
+
const from = this.reflexForwarded, buf = this.reflexBuf;
|
|
10317
|
+
if (from >= buf.length) return;
|
|
10318
|
+
const scrub = (seg, complete) => scrubSpokenJunk(seg.replace(/\([^()]*\)/g, (x) => STAGE_DIRECTION_RE.test(x) ? "" : x), complete);
|
|
10319
|
+
const marks = new Set(this.stepMarks.filter((k) => k > from && k <= buf.length));
|
|
10320
|
+
let out = buf.slice(0, from), at = from;
|
|
10321
|
+
const moved = /* @__PURE__ */ new Map();
|
|
10322
|
+
for (const k of [.../* @__PURE__ */ new Set([...marks, buf.length])].sort((x, y) => x - y)) {
|
|
10323
|
+
out += scrub(buf.slice(at, k), final || marks.has(k));
|
|
10324
|
+
moved.set(k, out.length);
|
|
10325
|
+
at = k;
|
|
10326
|
+
}
|
|
10327
|
+
if (out === buf) return;
|
|
10328
|
+
this.reflexBuf = out;
|
|
10329
|
+
this.stepMarks = this.stepMarks.map((k) => moved.get(k) ?? k);
|
|
10169
10330
|
}
|
|
10170
10331
|
/** Clear the per-turn guards. Called at the head of every voice turn (user send + re-voice flush). */
|
|
10171
10332
|
resetTurn() {
|
|
@@ -10179,6 +10340,8 @@ ${nowLine()}. Anchor every relative or time-sensitive reference \u2014 "today",
|
|
|
10179
10340
|
this.reflexForwarded = 0;
|
|
10180
10341
|
this.fabricationCut = false;
|
|
10181
10342
|
this.leakCuts = [];
|
|
10343
|
+
this.repeatCuts = [];
|
|
10344
|
+
this.midPhraseBounced = false;
|
|
10182
10345
|
this.leakStep = this.leakHit = false;
|
|
10183
10346
|
this.stepMarks = [];
|
|
10184
10347
|
this.leakVerdicts.clear();
|
|
@@ -10202,6 +10365,13 @@ ${nowLine()}. Anchor every relative or time-sensitive reference \u2014 "today",
|
|
|
10202
10365
|
return { block: true, reason: "Speculative turn aborted." };
|
|
10203
10366
|
}
|
|
10204
10367
|
if (this.nudging) return { block: true, reason: "Just say one short spoken acknowledgement \u2014 no tools this turn." };
|
|
10368
|
+
if ((call.name === "Act" || call.name === "Think") && !this.turnDispatched && !this.midPhraseBounced) {
|
|
10369
|
+
const said = [...this.voice.transcript].reverse().find((m) => m.role === "user");
|
|
10370
|
+
if (said && endsMidPhrase(contentText(said.content))) {
|
|
10371
|
+
this.midPhraseBounced = true;
|
|
10372
|
+
return { block: true, reason: "The user stopped mid-phrase \u2014 the rest of their request is still coming. Call Hold now (no filler) and wait for it; do not dispatch on a fragment." };
|
|
10373
|
+
}
|
|
10374
|
+
}
|
|
10205
10375
|
if (!this.turnDispatched) return;
|
|
10206
10376
|
if (call.name === "TaskStatus")
|
|
10207
10377
|
return { block: true, reason: "You just dispatched a task this turn \u2014 do NOT poll. Give one short spoken acknowledgement and end your turn; the result arrives later as a [task \u2026] event." };
|
|
@@ -10217,6 +10387,27 @@ ${nowLine()}. Anchor every relative or time-sensitive reference \u2014 "today",
|
|
|
10217
10387
|
noteExternalSpeech() {
|
|
10218
10388
|
this.externalSpeech = true;
|
|
10219
10389
|
}
|
|
10390
|
+
/** The user is speaking (a live partial, a barge): a pending Hold expiry waits for them again. */
|
|
10391
|
+
userActive() {
|
|
10392
|
+
if (this.holdTimer) this.armHoldResume();
|
|
10393
|
+
}
|
|
10394
|
+
clearHoldResume() {
|
|
10395
|
+
if (this.holdTimer) clearTimeout(this.holdTimer);
|
|
10396
|
+
this.holdTimer = null;
|
|
10397
|
+
}
|
|
10398
|
+
armHoldResume() {
|
|
10399
|
+
this.clearHoldResume();
|
|
10400
|
+
const ms = this.options.holdResumeMs;
|
|
10401
|
+
if (!ms) return;
|
|
10402
|
+
this.holdTimer = setTimeout(() => {
|
|
10403
|
+
this.holdTimer = null;
|
|
10404
|
+
this.holdResumed = true;
|
|
10405
|
+
this.notify("diag", "hold_expired", { ms });
|
|
10406
|
+
this.queueRevoice(_DuplexAgent.HOLD_EXPIRED);
|
|
10407
|
+
}, ms);
|
|
10408
|
+
this.holdTimer.unref?.();
|
|
10409
|
+
}
|
|
10410
|
+
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.]";
|
|
10220
10411
|
/** True when the just-finished turn voiced NOTHING — dead air to repair. Two ways this happens:
|
|
10221
10412
|
* (a) a dispatch with no spoken ack, and (b) an INLINE turn whose `final` channel came back empty —
|
|
10222
10413
|
* gpt-oss harmony sometimes puts the whole reply in `analysis` (→ thinking_delta, suppressed in
|
|
@@ -10279,6 +10470,8 @@ ${nowLine()}. Anchor every relative or time-sensitive reference \u2014 "today",
|
|
|
10279
10470
|
* second Act). The host gets `turn_replace` {id, replaces, rolledBack, followed?}: only a rollback drops the replaced
|
|
10280
10471
|
* line; `followed` = the kept turn whose follow-up ran (absent: the merged text ran). */
|
|
10281
10472
|
send(content, opts) {
|
|
10473
|
+
this.clearHoldResume();
|
|
10474
|
+
this.holdResumed = false;
|
|
10282
10475
|
if (opts?.replaces) this.replaceTurn(opts.replaces);
|
|
10283
10476
|
const cur = this.voice.options.signal;
|
|
10284
10477
|
const ext = cur && cur === this.turnSignal?.own ? this.turnSignal.ext : cur;
|
|
@@ -10535,6 +10728,20 @@ ${nowLine()}. Anchor every relative or time-sensitive reference \u2014 "today",
|
|
|
10535
10728
|
spec.decide("abort");
|
|
10536
10729
|
this.notify("diag", "speculation_aborted", { text: spec.text.slice(0, 80) });
|
|
10537
10730
|
}
|
|
10731
|
+
/** Fold a refinement into a RUNNING task's worker (Agent.inject — picked up at its next step) instead of cancel +
|
|
10732
|
+
* respawn: live 20261013-085238 one request spoken over three fragments became t5 → cancel → t6 → cancel → t7, each
|
|
10733
|
+
* worker restarting from zero. Returns the id when amended, undefined when the task is not running (→ dispatch new). */
|
|
10734
|
+
amendTask(id, brief) {
|
|
10735
|
+
const rec = this.tasks.get(id);
|
|
10736
|
+
if (!rec || rec.status !== "running" || !rec.worker || !brief.trim()) return void 0;
|
|
10737
|
+
rec.worker.inject(`[update from the user while you work \u2014 fold it into the SAME task, do not start over]
|
|
10738
|
+
${brief}`);
|
|
10739
|
+
rec.brief += `
|
|
10740
|
+
|
|
10741
|
+
Update: ${brief}`;
|
|
10742
|
+
this.notify("diag", "task_amended", { id, brief: brief.slice(0, 80) });
|
|
10743
|
+
return rec.id;
|
|
10744
|
+
}
|
|
10538
10745
|
/** Cancel a running background task — shared by the CancelTask tool and the CLI /tasks picker. */
|
|
10539
10746
|
cancelTask(id) {
|
|
10540
10747
|
const rec = this.tasks.get(id);
|
|
@@ -10821,14 +11028,15 @@ ${recent}` : brief) + verify + deliverContract;
|
|
|
10821
11028
|
signal: controller.signal
|
|
10822
11029
|
// shared with the checker so a cancel tears down both
|
|
10823
11030
|
};
|
|
10824
|
-
const
|
|
11031
|
+
const worker = new Agent(agentOpts);
|
|
11032
|
+
const promise = worker.run(briefText).then((res) => {
|
|
10825
11033
|
const { spoken, detail } = splitter.flush();
|
|
10826
11034
|
feedSpoken(spoken);
|
|
10827
11035
|
if (detail.trim()) pushTail(detail.trim());
|
|
10828
11036
|
flushSpoken();
|
|
10829
11037
|
return res;
|
|
10830
11038
|
}).then((res) => this.maybeVerify(id, brief, res, tier, agentOpts, askBridge)).then((res) => this.onWorkerSettled(id, res)).catch((err2) => this.onWorkerFailed(id, err2));
|
|
10831
|
-
this.tasks.set(id, { id, label, status: "running", controller, promise, tail, brief, followUp, splitter, createdTurn: this.turn });
|
|
11039
|
+
this.tasks.set(id, { id, label, status: "running", controller, promise, tail, brief, followUp, splitter, createdTurn: this.turn, worker });
|
|
10832
11040
|
if (this.tasks.size > this.options.maxTaskRecords)
|
|
10833
11041
|
for (const [tid, rec] of this.tasks) {
|
|
10834
11042
|
if (this.tasks.size <= this.options.maxTaskRecords) break;
|
|
@@ -11081,14 +11289,17 @@ Another agent just implemented the above. Independently check the CURRENT state
|
|
|
11081
11289
|
required: ["brief"],
|
|
11082
11290
|
properties: {
|
|
11083
11291
|
brief: { type: "string", description: "full, self-contained instructions for the worker" },
|
|
11084
|
-
label: { type: "string", description: "a short (2-4 word) label for the task" }
|
|
11292
|
+
label: { type: "string", description: "a short (2-4 word) label for the task" },
|
|
11293
|
+
refines: { type: "string", description: "id of a STILL-RUNNING task this message only adds detail to or corrects (a path, a name, a spelling) \u2014 the running worker is updated in place instead of starting a new task" }
|
|
11085
11294
|
}
|
|
11086
11295
|
},
|
|
11087
|
-
run: async ({ brief, label }) => {
|
|
11296
|
+
run: async ({ brief, label, refines }) => {
|
|
11088
11297
|
this.spokeBeforeDispatch = this.spokeThisTurn;
|
|
11089
11298
|
this.turnDispatched = true;
|
|
11090
11299
|
this.turnBriefs.add(String(brief ?? ""));
|
|
11091
11300
|
this.voice.options.toolChoice = "none";
|
|
11301
|
+
const amended = refines ? this.amendTask(String(refines), String(brief ?? "")) : void 0;
|
|
11302
|
+
if (amended) return this.dispatchResult(amended, "Updated the running");
|
|
11092
11303
|
const id = await this.dispatch(String(brief ?? ""), "act", label ? String(label) : void 0, this.turnFollowUp);
|
|
11093
11304
|
return this.dispatchResult(id, "Acting on");
|
|
11094
11305
|
}
|
|
@@ -11186,29 +11397,36 @@ Another agent just implemented the above. Independently check the CURRENT state
|
|
|
11186
11397
|
case "ls": {
|
|
11187
11398
|
if (!fs) return "unavailable (no filesystem)";
|
|
11188
11399
|
const p = String(path ?? ".");
|
|
11189
|
-
|
|
11190
|
-
|
|
11191
|
-
return names.slice(0, 50).join("\n") + (names.length > 50 ? `
|
|
11192
|
-
\u2026 (+${names.length - 50} more)` : "");
|
|
11193
|
-
} catch {
|
|
11194
|
-
const names = await fs.readDir(".").catch(() => []);
|
|
11195
|
-
return `'${p}' not found here \u2014 you are likely already inside it. Current directory listing:
|
|
11196
|
-
` + names.slice(0, 50).join("\n") + (names.length > 50 ? `
|
|
11400
|
+
const list = (at, names) => (at === p ? "" : `(${at})
|
|
11401
|
+
`) + names.slice(0, 50).join("\n") + (names.length > 50 ? `
|
|
11197
11402
|
\u2026 (+${names.length - 50} more)` : "");
|
|
11403
|
+
for (const at of this.lookCandidates(p)) {
|
|
11404
|
+
try {
|
|
11405
|
+
return list(at, await fs.readDir(at));
|
|
11406
|
+
} catch {
|
|
11407
|
+
}
|
|
11198
11408
|
}
|
|
11409
|
+
const cwd = (fs.getCwd?.() ?? "").replace(/\/+$/, "");
|
|
11410
|
+
if (cwd && p.replace(/^\.\/|\/+$/g, "") === cwd.split("/").pop()) return `'${p}' is the current directory:
|
|
11411
|
+
` + list(".", await fs.readDir(".").catch(() => []));
|
|
11412
|
+
return await this.lookMiss(p);
|
|
11199
11413
|
}
|
|
11200
11414
|
case "file": {
|
|
11201
11415
|
if (!fs) return "unavailable (no filesystem)";
|
|
11202
11416
|
if (!path) return "file lookup needs a path";
|
|
11203
|
-
|
|
11204
|
-
|
|
11205
|
-
|
|
11206
|
-
|
|
11207
|
-
|
|
11208
|
-
|
|
11209
|
-
|
|
11210
|
-
|
|
11417
|
+
for (const at of this.lookCandidates(String(path))) {
|
|
11418
|
+
let text;
|
|
11419
|
+
try {
|
|
11420
|
+
text = await fs.readFile(at);
|
|
11421
|
+
} catch {
|
|
11422
|
+
continue;
|
|
11423
|
+
}
|
|
11424
|
+
const head = at === String(path) ? "" : `(${at})
|
|
11425
|
+
`;
|
|
11426
|
+
return head + (text.length > CAP2 ? text.slice(0, CAP2) + `
|
|
11427
|
+
\u2026 (truncated \u2014 ${text.length} chars total; Act for the full file)` : text);
|
|
11211
11428
|
}
|
|
11429
|
+
return await this.lookMiss(String(path));
|
|
11212
11430
|
}
|
|
11213
11431
|
default:
|
|
11214
11432
|
return `unknown lookup '${what}'`;
|
|
@@ -11219,6 +11437,41 @@ Another agent just implemented the above. Independently check the CURRENT state
|
|
|
11219
11437
|
}
|
|
11220
11438
|
};
|
|
11221
11439
|
}
|
|
11440
|
+
/** Where QuickLook tries a path: as given (cwd-relative or absolute), then — relative only — under each lookRoot. */
|
|
11441
|
+
lookCandidates(p) {
|
|
11442
|
+
const rel = p.replace(/^~\/?/, "");
|
|
11443
|
+
const roots = this.options.lookRoots;
|
|
11444
|
+
if (p.startsWith("~")) return roots.length ? [`${roots[0].replace(/\/+$/, "")}/${rel}`.replace(/\/$/, "")] : [p];
|
|
11445
|
+
if (p.startsWith("/")) return [p];
|
|
11446
|
+
return [p, ...roots.map((r) => `${r.replace(/\/+$/, "")}/${rel}`)];
|
|
11447
|
+
}
|
|
11448
|
+
/** A QuickLook path not found anywhere: say where it looked and, at the deepest folder that DID exist, the few closest
|
|
11449
|
+
* names (a mis-heard segment — "Lead Sheets" for "Livshitz" — is usually one of them). Never the whole cwd listing:
|
|
11450
|
+
* live 20261013-085238 dumped it three times for three spellings of the same path. */
|
|
11451
|
+
async lookMiss(p) {
|
|
11452
|
+
const fs = this.options.fs;
|
|
11453
|
+
const segs = p.replace(/^~\/?/, "").split("/").filter((x) => x && x !== ".");
|
|
11454
|
+
const bases = p.startsWith("/") ? ["/"] : p.startsWith("~") ? this.options.lookRoots.slice(0, 1) : [".", ...this.options.lookRoots];
|
|
11455
|
+
let best;
|
|
11456
|
+
for (const base of bases) {
|
|
11457
|
+
let dir = base;
|
|
11458
|
+
for (let i = 0; i < segs.length; i++) {
|
|
11459
|
+
const names = await fs.readDir(dir).catch(() => void 0);
|
|
11460
|
+
if (!names) break;
|
|
11461
|
+
const hit = names.find((n) => n.toLowerCase() === segs[i].toLowerCase());
|
|
11462
|
+
if (!hit) {
|
|
11463
|
+
if (i > (best?.depth ?? -1)) best = { dir, depth: i, names };
|
|
11464
|
+
break;
|
|
11465
|
+
}
|
|
11466
|
+
dir = dir === "." ? hit : `${dir.replace(/\/+$/, "")}/${hit}`;
|
|
11467
|
+
}
|
|
11468
|
+
}
|
|
11469
|
+
const where = ["the current folder", ...this.options.lookRoots].join(", ");
|
|
11470
|
+
if (!best || !best.names.length) return `'${p}' not found (looked under ${where}). Ask the user for the exact path, or Act to search for it.`;
|
|
11471
|
+
const missing = segs[best.depth];
|
|
11472
|
+
const close = [...best.names].sort((x, y) => nameScore(missing, y) - nameScore(missing, x)).slice(0, 6);
|
|
11473
|
+
return `'${p}' not found (looked under ${where}). '${missing}' is not in ${best.dir} \u2014 closest names there: ${close.join(", ")}. If one of these is what they meant (speech-to-text often mishears names), use it; otherwise ask the user or Act to search.`;
|
|
11474
|
+
}
|
|
11222
11475
|
answerTaskTool() {
|
|
11223
11476
|
return {
|
|
11224
11477
|
name: "AnswerTask",
|
|
@@ -11247,6 +11500,8 @@ Another agent just implemented the above. Independently check the CURRENT state
|
|
|
11247
11500
|
}
|
|
11248
11501
|
},
|
|
11249
11502
|
run: async ({ filler }) => {
|
|
11503
|
+
if (this.heldThisTurn) return "Already holding \u2014 stay silent.";
|
|
11504
|
+
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.";
|
|
11250
11505
|
this.heldThisTurn = true;
|
|
11251
11506
|
this.notify("hold_filler", filler ? String(filler) : "");
|
|
11252
11507
|
return "Holding \u2014 listening for the rest of the user's thought. Do not respond further this turn.";
|
|
@@ -11256,13 +11511,21 @@ Another agent just implemented the above. Independently check the CURRENT state
|
|
|
11256
11511
|
cancelTaskTool() {
|
|
11257
11512
|
return {
|
|
11258
11513
|
name: "CancelTask",
|
|
11259
|
-
description:
|
|
11260
|
-
parameters: { type: "object", required: ["id"], properties: { id: { type: "string" } } },
|
|
11514
|
+
description: 'Cancel a running background task by id \u2014 or id "all" to cancel EVERY running task (the user said "stop everything", "stop all the searches").',
|
|
11515
|
+
parameters: { type: "object", required: ["id"], properties: { id: { type: "string", description: 'a task id, or "all"' } } },
|
|
11261
11516
|
// Cancel-and-redirect ("stop — do Y instead") is ONE user line: live, the reflex cancelled, said "fetching Y now" and
|
|
11262
11517
|
// never dispatched (2/5 Bare voice runs). The tool result is where it looks next — tell it the redirect needs Act.
|
|
11518
|
+
// "Stop all the searches" cancelled one task (and hit a done one) and left t4 running (live 20261013-085238): the
|
|
11519
|
+
// result always names what is STILL running, and "all" stops every one in a single call.
|
|
11263
11520
|
run: async ({ id }) => {
|
|
11264
|
-
const
|
|
11265
|
-
|
|
11521
|
+
const want = String(id ?? "").trim();
|
|
11522
|
+
const running = () => [...this.tasks.values()].filter((t) => t.status === "running");
|
|
11523
|
+
const ids = /^(?:all|\*|everything)$/i.test(want) ? running().map((t) => t.id) : [want];
|
|
11524
|
+
const lines = ids.length ? ids.map((x) => this.cancelTask(x)) : ["No running tasks."];
|
|
11525
|
+
const left = running();
|
|
11526
|
+
const tail = left.length ? ` Still running: ${left.map((t) => `${t.id} (${t.label})`).join(", ")} \u2014 if the user asked to stop ALL, cancel these too (or call CancelTask with id "all").` : " No tasks are running now.";
|
|
11527
|
+
const cancelled = ids.some((x) => this.tasks.get(x)?.status === "cancelled");
|
|
11528
|
+
return lines.join(" ") + tail + (cancelled ? ` If the user's message ALSO asked for different work (a redirect \u2014 "instead\u2026", "actually\u2026", "do Y"), call Act NOW with that new brief: saying you are on it without calling Act does nothing. If they only asked to stop, just acknowledge in a few words.` : "");
|
|
11266
11529
|
}
|
|
11267
11530
|
};
|
|
11268
11531
|
}
|
|
@@ -13192,6 +13455,8 @@ var VoiceIOOptions = class extends VoiceEngineOptions {
|
|
|
13192
13455
|
sonioxApiKey = process.env.SONIOX_API_KEY ?? "";
|
|
13193
13456
|
cartesiaApiKey = process.env.CARTESIA_API_KEY ?? "";
|
|
13194
13457
|
cartesiaVoiceId = process.env.CARTESIA_VOICE_ID ?? "";
|
|
13458
|
+
/** STT custom vocabulary (names/companies/projects) — biases Soniox toward them (see memoryTerms). */
|
|
13459
|
+
sttTerms = [];
|
|
13195
13460
|
emotions = process.env.VOICE_EMOTIONS !== "0";
|
|
13196
13461
|
// Cartesia inline emotion tags (sonic-3)
|
|
13197
13462
|
/** CLI voice default: mask a slow reflex TTFT with a one-shot varied micro-ack (a FIXED per-turn
|
|
@@ -13222,7 +13487,7 @@ var VoiceIO = class extends VoiceEngine {
|
|
|
13222
13487
|
const duplex = bin ? new AecDuplexAudio(bin) : null;
|
|
13223
13488
|
super({
|
|
13224
13489
|
...o,
|
|
13225
|
-
stt: o.stt ?? new SonioxSTT({ auth: o.sonioxApiKey, source: duplex ?? new NodeMicSource() }),
|
|
13490
|
+
stt: o.stt ?? new SonioxSTT({ auth: o.sonioxApiKey, source: duplex ?? new NodeMicSource(), terms: o.sttTerms }),
|
|
13226
13491
|
tts: o.tts ?? new CartesiaTTS({ auth: o.cartesiaApiKey, voiceId: o.cartesiaVoiceId }),
|
|
13227
13492
|
player: o.player ?? duplex ?? new Player(),
|
|
13228
13493
|
bargeRmsMult: Number(process.env.BARGE_RMS_MULT || o.bargeRmsMult),
|
|
@@ -13429,7 +13694,7 @@ var scriptOf = (w) => SCRIPTS.find(([re]) => {
|
|
|
13429
13694
|
re.lastIndex = 0;
|
|
13430
13695
|
return re.test(w);
|
|
13431
13696
|
})?.[1] ?? null;
|
|
13432
|
-
function detectLanguage(text) {
|
|
13697
|
+
function detectLanguage(text, switching = false) {
|
|
13433
13698
|
const ws = text.toLowerCase().match(new RegExp("\\p{L}+", "gu")) ?? [];
|
|
13434
13699
|
const n = /* @__PURE__ */ new Map();
|
|
13435
13700
|
for (const w of ws) {
|
|
@@ -13437,9 +13702,10 @@ function detectLanguage(text) {
|
|
|
13437
13702
|
if (s) n.set(s, (n.get(s) ?? 0) + 1);
|
|
13438
13703
|
}
|
|
13439
13704
|
const [best, c] = [...n].sort((a2, b2) => b2[1] - a2[1])[0] ?? [null, 0];
|
|
13440
|
-
if (best !== "latin") return c >= 1 && (best === "zh" || best === "ja" || c >= 2 || ws.length === 1) ? best : null;
|
|
13705
|
+
if (best !== "latin") return c >= 1 && (best === "zh" || best === "ja" || c >= 2 || ws.length === 1 && !switching) ? best : null;
|
|
13441
13706
|
const score = Object.entries(STOP3).map(([l2, s]) => [l2, ws.filter((w) => s.has(w)).length]).sort((a2, b2) => b2[1] - a2[1]);
|
|
13442
13707
|
const [[l, a], [, b]] = score;
|
|
13708
|
+
if (switching) return a - b >= 2 && ws.length >= 4 ? l : null;
|
|
13443
13709
|
return a > b && (a >= 2 || a === 1 && ws.length <= 3) ? l : null;
|
|
13444
13710
|
}
|
|
13445
13711
|
var NAMES = new Intl.DisplayNames(["en"], { type: "language" });
|
|
@@ -13488,8 +13754,8 @@ function validateBank(raw, lang, minPerKind = 3) {
|
|
|
13488
13754
|
}
|
|
13489
13755
|
var KIND_HINT = {
|
|
13490
13756
|
backchannel: 'tiny listener cues while they keep talking (like "mm-hm", "right") \u2014 never takes the turn',
|
|
13491
|
-
filler: 'soft
|
|
13492
|
-
bridge:
|
|
13757
|
+
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',
|
|
13758
|
+
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")`,
|
|
13493
13759
|
phatic: 'greetings, thanks and yes/no openers a person starts a turn with (like "hi", "thanks", "how are you")'
|
|
13494
13760
|
};
|
|
13495
13761
|
function fastBankGenerator(ai, model, perKind = 12) {
|
|
@@ -13522,6 +13788,7 @@ var ChannelBanksOptions = class {
|
|
|
13522
13788
|
fallback = { en: EN_BANK };
|
|
13523
13789
|
now = () => Date.now();
|
|
13524
13790
|
};
|
|
13791
|
+
var BANK_VERSION = 2;
|
|
13525
13792
|
var ChannelBanks = class {
|
|
13526
13793
|
constructor(options) {
|
|
13527
13794
|
this.options = options;
|
|
@@ -13552,6 +13819,10 @@ var ChannelBanks = class {
|
|
|
13552
13819
|
if (e.code !== "ENOENT") log29.warn(`bank cache ${f} unreadable \u2014 regenerating: ${e.message}`);
|
|
13553
13820
|
return null;
|
|
13554
13821
|
});
|
|
13822
|
+
if (cached && cached.v !== BANK_VERSION) {
|
|
13823
|
+
log29.debug(`bank ${lang}: cache v${cached.v ?? 1} predates v${BANK_VERSION} \u2014 regenerating`);
|
|
13824
|
+
cached = null;
|
|
13825
|
+
}
|
|
13555
13826
|
const valid = cached && validateBank(cached.bank, lang, 0);
|
|
13556
13827
|
const complete = !!valid && BANK_KEYS.every((k) => valid[k].length);
|
|
13557
13828
|
if (valid) {
|
|
@@ -13578,7 +13849,7 @@ var ChannelBanks = class {
|
|
|
13578
13849
|
this.banks.set(lang, bank);
|
|
13579
13850
|
log29.debug(`bank ${lang}: ${have ? "refreshed" : "generated"} (${BANK_KEYS.map((k) => bank[k].length).join("/")}) in ${Math.round(this.o.now() - t0)}ms`);
|
|
13580
13851
|
const f = this.file(lang);
|
|
13581
|
-
if (f) await mkdir(this.o.cacheDir, { recursive: true }).then(() => writeFile(f, JSON.stringify({ at: this.o.now(), bank }))).catch((e) => log29.warn(`bank cache write ${f} failed: ${e.message}`));
|
|
13852
|
+
if (f) await mkdir(this.o.cacheDir, { recursive: true }).then(() => writeFile(f, JSON.stringify({ at: this.o.now(), v: BANK_VERSION, bank }))).catch((e) => log29.warn(`bank cache write ${f} failed: ${e.message}`));
|
|
13582
13853
|
} catch (e) {
|
|
13583
13854
|
log29.warn(`bank ${lang}: generation failed \u2014 ${have ? "keeping the cached bank" : lang in this.o.fallback ? "fallback bank" : "silent"}: ${e.message}`);
|
|
13584
13855
|
} finally {
|
|
@@ -13601,7 +13872,8 @@ var SecondChannelOptions = class {
|
|
|
13601
13872
|
* tell it confidently; the channel is silent until then (never English by default). */
|
|
13602
13873
|
lang;
|
|
13603
13874
|
/** Kinds worth generating (content-conditioned); the rest are canned sounds. */
|
|
13604
|
-
generateFor = ["
|
|
13875
|
+
generateFor = ["bridge"];
|
|
13876
|
+
// a backchannel is timing, not content: canned at 0ms (a generated one arrived after the micro-pause)
|
|
13605
13877
|
/** Host TTS on the second channel; resolves when the line finished playing. `false` = the host could not play it. */
|
|
13606
13878
|
speak = () => {
|
|
13607
13879
|
};
|
|
@@ -13627,7 +13899,12 @@ var SecondChannelOptions = class {
|
|
|
13627
13899
|
avoidLines = 4;
|
|
13628
13900
|
/** Canned picks exclude the last N picks of the same kind. */
|
|
13629
13901
|
bankExclude = 2;
|
|
13630
|
-
backchannelMinWords =
|
|
13902
|
+
backchannelMinWords = 6;
|
|
13903
|
+
/** No new interim for this long mid-utterance (a breath / clause gap, shorter than a pause) → a backchannel moment. */
|
|
13904
|
+
backchannelPauseMs = 350;
|
|
13905
|
+
/** A bridge waits this long after the user's turn is dispatched, and plays only if channel-1 has said nothing yet
|
|
13906
|
+
* (a fast reply/ack needs no bridge — one before it reads as the agent starting a different sentence). 0 = at once. */
|
|
13907
|
+
bridgeDelayMs = 1200;
|
|
13631
13908
|
/** No new interim for this long → a pause (hosts may also call onPause). */
|
|
13632
13909
|
fillerPauseMs = 700;
|
|
13633
13910
|
fillerMinWords = 4;
|
|
@@ -13660,13 +13937,14 @@ var SecondChannel = class {
|
|
|
13660
13937
|
heard = "";
|
|
13661
13938
|
userSpeaking = false;
|
|
13662
13939
|
main = "idle";
|
|
13663
|
-
lead = "";
|
|
13664
13940
|
gen = 0;
|
|
13665
13941
|
// bumped by barge-in / a new user turn → in-flight decisions drop
|
|
13666
13942
|
decidedAt = 0;
|
|
13667
13943
|
// word count at the last decision
|
|
13668
13944
|
busy = false;
|
|
13669
13945
|
pauseTimer = null;
|
|
13946
|
+
bcTimer = null;
|
|
13947
|
+
bridgeTimer = null;
|
|
13670
13948
|
lastAt = -Infinity;
|
|
13671
13949
|
lastKind = null;
|
|
13672
13950
|
live = null;
|
|
@@ -13698,9 +13976,11 @@ var SecondChannel = class {
|
|
|
13698
13976
|
this.picks.clear();
|
|
13699
13977
|
void this.o.banks?.ensure(lang);
|
|
13700
13978
|
}
|
|
13979
|
+
/** Once a language is known, switching needs strong evidence (detectLanguage's `switching`): an English "Um," read as
|
|
13980
|
+
* Portuguese flipped a live session to pt (silence until a pt bank was generated — it then spoke Portuguese). */
|
|
13701
13981
|
detect(text) {
|
|
13702
13982
|
if (this.pinned) return;
|
|
13703
|
-
const l = detectLanguage(text);
|
|
13983
|
+
const l = detectLanguage(text, !!this.lang);
|
|
13704
13984
|
if (l) this.setLanguage(l);
|
|
13705
13985
|
}
|
|
13706
13986
|
get bank() {
|
|
@@ -13722,6 +14002,11 @@ var SecondChannel = class {
|
|
|
13722
14002
|
this.pauseTimer = null;
|
|
13723
14003
|
this.onPause(this.o.fillerPauseMs);
|
|
13724
14004
|
}, this.o.fillerPauseMs);
|
|
14005
|
+
if (this.bcTimer) clearTimeout(this.bcTimer);
|
|
14006
|
+
this.bcTimer = this.o.aec ? setTimeout(() => {
|
|
14007
|
+
this.bcTimer = null;
|
|
14008
|
+
if (this.userSpeaking && this.heard === t) void this.consider("interim", 0, true);
|
|
14009
|
+
}, this.o.backchannelPauseMs) : null;
|
|
13725
14010
|
if (words2(t).length - this.decidedAt >= this.o.decideEveryWords) void this.consider("interim");
|
|
13726
14011
|
}
|
|
13727
14012
|
/** The user went quiet for `ms` (host VAD/STT pause, or the internal interim timer). */
|
|
@@ -13730,6 +14015,10 @@ var SecondChannel = class {
|
|
|
13730
14015
|
clearTimeout(this.pauseTimer);
|
|
13731
14016
|
this.pauseTimer = null;
|
|
13732
14017
|
}
|
|
14018
|
+
if (this.bcTimer) {
|
|
14019
|
+
clearTimeout(this.bcTimer);
|
|
14020
|
+
this.bcTimer = null;
|
|
14021
|
+
}
|
|
13733
14022
|
this.userSpeaking = false;
|
|
13734
14023
|
void this.consider("pause", ms);
|
|
13735
14024
|
}
|
|
@@ -13738,22 +14027,30 @@ var SecondChannel = class {
|
|
|
13738
14027
|
if (s === this.main) return;
|
|
13739
14028
|
this.main = s;
|
|
13740
14029
|
if (s === "first_audio" || s === "speaking") this.live = null;
|
|
14030
|
+
if (s !== "queued" && s !== "thinking" && this.bridgeTimer) {
|
|
14031
|
+
clearTimeout(this.bridgeTimer);
|
|
14032
|
+
this.bridgeTimer = null;
|
|
14033
|
+
}
|
|
13741
14034
|
if (s === "idle") {
|
|
13742
14035
|
this.heard = "";
|
|
13743
14036
|
this.decidedAt = 0;
|
|
13744
|
-
this.lead = "";
|
|
13745
14037
|
this.bridged = false;
|
|
13746
14038
|
return;
|
|
13747
14039
|
}
|
|
13748
14040
|
if (s === "queued") {
|
|
13749
14041
|
this.gen++;
|
|
13750
14042
|
this.userSpeaking = false;
|
|
13751
|
-
this.lead = "";
|
|
13752
14043
|
this.bridged = false;
|
|
13753
|
-
|
|
13754
|
-
|
|
14044
|
+
for (const k of ["pauseTimer", "bcTimer", "bridgeTimer"]) {
|
|
14045
|
+
if (this[k]) clearTimeout(this[k]);
|
|
14046
|
+
this[k] = null;
|
|
14047
|
+
}
|
|
14048
|
+
if (this.o.bridgeDelayMs > 0) this.bridgeTimer = setTimeout(() => {
|
|
14049
|
+
this.bridgeTimer = null;
|
|
14050
|
+
void this.consider("working");
|
|
14051
|
+
}, this.o.bridgeDelayMs);
|
|
14052
|
+
else void this.consider("working");
|
|
13755
14053
|
}
|
|
13756
|
-
if (s === "queued" || s === "thinking" || s === "streaming_text") void this.consider("working");
|
|
13757
14054
|
}
|
|
13758
14055
|
/** The user's final line was dispatched to the main agent (its words are the bridge's micro-context). */
|
|
13759
14056
|
onUserTurn(text) {
|
|
@@ -13762,10 +14059,9 @@ var SecondChannel = class {
|
|
|
13762
14059
|
this.main = "idle";
|
|
13763
14060
|
this.onAgentState("queued");
|
|
13764
14061
|
}
|
|
13765
|
-
/** The main answer's streamed text (before its audio):
|
|
14062
|
+
/** The main answer's streamed text (before its audio): channel-1 is responding — no bridge from here on. */
|
|
13766
14063
|
onMainTokens(text) {
|
|
13767
|
-
if (this.
|
|
13768
|
-
if (this.main === "queued" || this.main === "thinking") this.onAgentState("streaming_text");
|
|
14064
|
+
if (text.trim() && (this.main === "queued" || this.main === "thinking")) this.onAgentState("streaming_text");
|
|
13769
14065
|
}
|
|
13770
14066
|
/** The user barged in: cut the in-flight line now and drop every pending decision. */
|
|
13771
14067
|
onBargeIn() {
|
|
@@ -13794,9 +14090,6 @@ var SecondChannel = class {
|
|
|
13794
14090
|
get audioLocked() {
|
|
13795
14091
|
return this.main === "first_audio" || this.main === "speaking";
|
|
13796
14092
|
}
|
|
13797
|
-
get working() {
|
|
13798
|
-
return this.main === "queued" || this.main === "thinking" || this.main === "streaming_text";
|
|
13799
|
-
}
|
|
13800
14093
|
tail() {
|
|
13801
14094
|
return words2(this.heard).slice(-this.o.microWords).join(" ");
|
|
13802
14095
|
}
|
|
@@ -13805,21 +14098,29 @@ var SecondChannel = class {
|
|
|
13805
14098
|
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;
|
|
13806
14099
|
if (k === "backchannel") return this.o.aec && this.userSpeaking && this.main === "idle";
|
|
13807
14100
|
if (k === "filler") return !this.userSpeaking && this.main === "idle";
|
|
13808
|
-
return this.
|
|
14101
|
+
return (this.main === "queued" || this.main === "thinking") && !this.bridged;
|
|
13809
14102
|
}
|
|
13810
14103
|
/** 0ms rule: the candidate line for this trigger, or silence. */
|
|
13811
|
-
rule(trigger, pauseMs = 0) {
|
|
14104
|
+
rule(trigger, pauseMs = 0, breath = false) {
|
|
13812
14105
|
const w = words2(this.heard);
|
|
13813
14106
|
if (!this.lang) return "silence";
|
|
13814
14107
|
if (w.length <= 3 || w.length <= 6 && isPhatic(this.heard, this.bank)) return "silence";
|
|
13815
|
-
const k = trigger === "interim" ? w.length
|
|
14108
|
+
const k = trigger === "interim" ? this.backchannelMoment(w.length, breath) ? "backchannel" : null : trigger === "pause" ? pauseMs >= this.o.fillerPauseMs && w.length >= this.o.fillerMinWords ? "filler" : null : w.length >= this.o.bridgeMinWords ? "bridge" : null;
|
|
13816
14109
|
return k && this.allowed(k) ? k : "silence";
|
|
13817
14110
|
}
|
|
13818
|
-
|
|
14111
|
+
/** A clause boundary mid-floor: a breath or a comma-like close. Not a question (it wants the answer, not "mm-hm"), and a
|
|
14112
|
+
* sentence end only deep into a story — a short request's end is the end of their turn (replay: "mm-hm" right after
|
|
14113
|
+
* "Check hacker news for recent news.", just before the final). */
|
|
14114
|
+
backchannelMoment(n, breath) {
|
|
14115
|
+
if (n < this.o.backchannelMinWords || /\?$/.test(this.heard)) return false;
|
|
14116
|
+
if (/[.!]$/.test(this.heard)) return n >= 2 * this.o.backchannelMinWords;
|
|
14117
|
+
return breath || /[,;:…]$/.test(this.heard);
|
|
14118
|
+
}
|
|
14119
|
+
async consider(trigger, pauseMs, breath = false) {
|
|
13819
14120
|
if (this.busy) return;
|
|
13820
14121
|
const n0 = words2(this.heard).length;
|
|
13821
14122
|
this.decidedAt = n0;
|
|
13822
|
-
const rule = this.rule(trigger, pauseMs);
|
|
14123
|
+
const rule = this.rule(trigger, pauseMs, breath);
|
|
13823
14124
|
if (rule === "silence") return;
|
|
13824
14125
|
this.busy = true;
|
|
13825
14126
|
const gen = this.gen, t0 = this.o.now();
|
|
@@ -13859,7 +14160,7 @@ var SecondChannel = class {
|
|
|
13859
14160
|
async line(kind) {
|
|
13860
14161
|
if (this.o.generate && this.o.generateFor.includes(kind)) {
|
|
13861
14162
|
const ac = new AbortController();
|
|
13862
|
-
const ctx = { kind, heard: this.tail(), state: this.main, avoid: [...this.recent], lang: this.lang
|
|
14163
|
+
const ctx = { kind, heard: this.tail(), state: this.main, avoid: [...this.recent], lang: this.lang };
|
|
13863
14164
|
const raw = await withBudget(this.o.generate(ctx, ac.signal), this.o.generateBudgetMs, `${kind} generate`);
|
|
13864
14165
|
if (raw === null) ac.abort();
|
|
13865
14166
|
const text = cleanLine(raw ?? "", this.lang);
|
|
@@ -13907,17 +14208,16 @@ var SecondChannel = class {
|
|
|
13907
14208
|
}
|
|
13908
14209
|
};
|
|
13909
14210
|
var KIND_HINT2 = {
|
|
13910
|
-
backchannel:
|
|
13911
|
-
filler:
|
|
13912
|
-
bridge:
|
|
14211
|
+
backchannel: 'a tiny listener cue while they keep talking ("mm-hm", "right", "yeah") \u2014 shows you follow, never takes the turn',
|
|
14212
|
+
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',
|
|
14213
|
+
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")'
|
|
13913
14214
|
};
|
|
13914
14215
|
function fastLineGenerator(ai, model) {
|
|
13915
14216
|
return async (c, signal) => {
|
|
13916
14217
|
const prompt = `You voice the tiny side-sounds of a live spoken conversation. Write ONE line of 1-5 words: ${KIND_HINT2[c.kind]}.
|
|
13917
14218
|
The user just said: "${c.heard}"
|
|
13918
14219
|
The assistant is: ${c.state}.
|
|
13919
|
-
` + (c.
|
|
13920
|
-
` : "") + (c.avoid.length ? `Do not reuse any of: ${c.avoid.map((a) => `"${a}"`).join(", ")}.
|
|
14220
|
+
` + (c.avoid.length ? `Do not reuse any of: ${c.avoid.map((a) => `"${a}"`).join(", ")}.
|
|
13921
14221
|
` : "") + `React to their actual words, casually, in ${languageName(c.lang)}. Output only the line.`;
|
|
13922
14222
|
const reasoning = /gpt-oss/.test(model) ? { providerOptions: { reasoning_effort: "low" } } : {};
|
|
13923
14223
|
const r = await ai.chat({ model, messages: [{ role: "user", content: prompt }], stream: false, signal, maxTokens: 150, ...reasoning });
|
|
@@ -17055,7 +17355,8 @@ function parseArgs(argv) {
|
|
|
17055
17355
|
else if (x === "--duplex-v2") {
|
|
17056
17356
|
a.duplex = true;
|
|
17057
17357
|
a.duplexV2 = true;
|
|
17058
|
-
} else if (x === "--
|
|
17358
|
+
} else if (x === "--channel-only") a.channelOnly = true;
|
|
17359
|
+
else if (x === "--conversational" || x === "--convo" || x === "--voice") {
|
|
17059
17360
|
a.voice = true;
|
|
17060
17361
|
a.duplex = true;
|
|
17061
17362
|
} else if (x === "--voice-model") a.voiceModel = val(++i, x);
|
|
@@ -17094,10 +17395,11 @@ function parseArgs(argv) {
|
|
|
17094
17395
|
if (a.seed && !a.boddb) throw new Error("--seed only applies with --boddb (it seeds the database from cwd on first run)");
|
|
17095
17396
|
if (a.worktree && (a.vfs || a.boddb)) throw new Error("--worktree is a real-disk concept \u2014 incompatible with --vfs/--boddb");
|
|
17096
17397
|
if (a.duplex && (a.task || a.print)) throw new Error("--duplex is interactive-only (a conversational mode) \u2014 drop the task/-p");
|
|
17097
|
-
if (a.inputFormat && (!a.duplex && !a.print || a.outputFormat !== "stream-json")) throw new Error("--input-format stream-json needs --output-format stream-json and --duplex (or --voice) or -p");
|
|
17398
|
+
if (a.inputFormat && (!a.duplex && !a.print && !a.channelOnly || a.outputFormat !== "stream-json")) throw new Error("--input-format stream-json needs --output-format stream-json and --duplex (or --voice), --channel-only or -p");
|
|
17399
|
+
if (a.channelOnly && !a.inputFormat) throw new Error("--channel-only needs --input-format stream-json --output-format stream-json");
|
|
17098
17400
|
if (a.duplex && a.plan) throw new Error("--plan is not supported in --duplex (workers are non-interactive; a plan could never be approved)");
|
|
17099
17401
|
if (a.voiceSystemPrompt && !a.duplex) throw new Error("--voice-system-prompt only applies with --duplex");
|
|
17100
|
-
if (a.voiceModel && !a.duplex) throw new Error("--voice-model only applies with --duplex");
|
|
17402
|
+
if (a.voiceModel && !a.duplex && !a.channelOnly) throw new Error("--voice-model only applies with --duplex or --channel-only");
|
|
17101
17403
|
if (a.thinkModel !== void 0 && !a.duplex) throw new Error("--think-model/--no-think only apply with --duplex");
|
|
17102
17404
|
if (!canPrompt && (a.ask || a.plan))
|
|
17103
17405
|
throw new Error(`${a.ask ? "--ask" : "--plan"} needs an interactive terminal to approve on \u2014 none is attached (stdin/stderr are not a TTY). For an unattended run use --yes, or pre-authorize with rules in .agent/permissions.json.`);
|
|
@@ -17141,6 +17443,8 @@ Flags:
|
|
|
17141
17443
|
to a background worker agent (-m model); results are re-voiced when ready
|
|
17142
17444
|
--duplex-v2 duplex + a second voice channel: backchannels, fillers and bridges that never
|
|
17143
17445
|
enter the agent's history (headless: stdin interim/pause/agent_state, stdout channel)
|
|
17446
|
+
--channel-only headless (--input-format stream-json): ONLY the second channel, for a host whose main
|
|
17447
|
+
answer comes from elsewhere (stdin user/text/barge/interim/pause/agent_state, stdout channel)
|
|
17144
17448
|
--conversational duplex with a conversation-native register \u2014 short fast turns, fillers,
|
|
17145
17449
|
impulsive reactions, human pacing (implies --duplex; aliases: --convo, --voice)
|
|
17146
17450
|
with SONIOX_API_KEY + CARTESIA_API_KEY(+VOICE_ID) set: real voice I/O \u2014 mic in,
|
|
@@ -18584,6 +18888,8 @@ async function repl(args, ai, cfg, cwd) {
|
|
|
18584
18888
|
onTaskStart: async (_id, label) => {
|
|
18585
18889
|
await checkpoints.begin(label);
|
|
18586
18890
|
},
|
|
18891
|
+
lookRoots: [homedir10(), join16(homedir10(), "Documents")],
|
|
18892
|
+
// QuickLook: a spoken "Projects/X" usually means ~/Documents/Projects/X
|
|
18587
18893
|
// The jail deny-lists .git/** (VCS internals can carry credentials), so the engine's fs-based
|
|
18588
18894
|
// 'branch' lookup can't see it — supply it host-side (one safe read-only file).
|
|
18589
18895
|
quickLook: {
|
|
@@ -20216,8 +20522,14 @@ ${out}
|
|
|
20216
20522
|
}
|
|
20217
20523
|
const fakeVoice = process.env.AGENTX_VOICE_FAKE ? fakeVoiceParts(process.env.AGENTX_VOICE_FAKE) : null;
|
|
20218
20524
|
voiceDiag = new JsonlDiagSink(join16(store.dir, `${session.meta.id}.voice.jsonl`));
|
|
20525
|
+
const sttTerms = agent.options.fs && agent.options.memoryDir ? await memoryTerms(agent.options.fs, agent.options.memoryDir, [basename4(agent.options.fs?.getCwd() ?? process.cwd())]).catch((e) => {
|
|
20526
|
+
err(dim(` (stt terms: ${e?.message ?? e})
|
|
20527
|
+
`));
|
|
20528
|
+
return [];
|
|
20529
|
+
}) : [basename4(agent.options.fs?.getCwd() ?? process.cwd())];
|
|
20219
20530
|
voiceIO = new VoiceIO({
|
|
20220
20531
|
...fakeVoice ?? {},
|
|
20532
|
+
sttTerms,
|
|
20221
20533
|
onDiag: (ev) => voiceDiag?.push(ev),
|
|
20222
20534
|
emotions: emotionsOn,
|
|
20223
20535
|
showEmotions,
|
|
@@ -20243,6 +20555,7 @@ ${out}
|
|
|
20243
20555
|
onPartial: (text) => {
|
|
20244
20556
|
if (text === voicePartial) return;
|
|
20245
20557
|
voicePartial = text;
|
|
20558
|
+
if (text.trim()) dx?.userActive();
|
|
20246
20559
|
if (!partialRedraw) partialRedraw = setTimeout(() => {
|
|
20247
20560
|
partialRedraw = null;
|
|
20248
20561
|
editorRef?.redrawNow();
|
|
@@ -20375,6 +20688,7 @@ ${out}
|
|
|
20375
20688
|
};
|
|
20376
20689
|
try {
|
|
20377
20690
|
await voiceIO.start();
|
|
20691
|
+
if (dx) dx.options.holdResumeMs = HOLD_RESUME_MS;
|
|
20378
20692
|
if (args.duplexV2 && dx) {
|
|
20379
20693
|
const v = voiceIO;
|
|
20380
20694
|
bindSecondChannel(v, makeSecondChannel(ai, dx.options.reflexModel, { show: (t) => {
|
|
@@ -20632,6 +20946,7 @@ function duplexError(e, reflexModel) {
|
|
|
20632
20946
|
const code = e?.statusCode;
|
|
20633
20947
|
return code && !msg.includes(String(code)) ? `${msg} (${code})` : msg;
|
|
20634
20948
|
}
|
|
20949
|
+
var HOLD_RESUME_MS = 4e3;
|
|
20635
20950
|
var leakDecider = () => {
|
|
20636
20951
|
const o = voiceDeciderOptions();
|
|
20637
20952
|
return o ? new DecideClient(o) : void 0;
|
|
@@ -20663,8 +20978,10 @@ async function duplexServe(args, ai, cfg, cwd) {
|
|
|
20663
20978
|
sc?.onMainTokens(String(e.message ?? ""));
|
|
20664
20979
|
} else if (k === "thinking_delta") out({ type: "thinking", text: e.message });
|
|
20665
20980
|
else if (k === "speak_utterance") out({ type: "speak", text: e.message });
|
|
20666
|
-
else if (k === "hold_filler")
|
|
20667
|
-
|
|
20981
|
+
else if (k === "hold_filler") {
|
|
20982
|
+
out({ type: "filler", text: e.message });
|
|
20983
|
+
sc?.onAgentState("idle");
|
|
20984
|
+
} else if (k === "turn_replace") out({ type: "replaced", ...e.data });
|
|
20668
20985
|
else if (k === "revoice_done") {
|
|
20669
20986
|
out({ type: "revoice_done" });
|
|
20670
20987
|
persist();
|
|
@@ -20690,7 +21007,9 @@ async function duplexServe(args, ai, cfg, cwd) {
|
|
|
20690
21007
|
host,
|
|
20691
21008
|
// --voice: the CLI voice register (progress asides, spoken budget). No emotion tags: the host's TTS may not speak them.
|
|
20692
21009
|
...args.voice ? { voiceStyle: "conversational", progressUpdates: true, spokenBudgetChars: 350 } : {},
|
|
20693
|
-
reflexOptions: { fs: agent.options.fs, tools: [exitSessionTool(() => out({ type: "exit" }))] }
|
|
21010
|
+
reflexOptions: { fs: agent.options.fs, tools: [exitSessionTool(() => out({ type: "exit" }))] },
|
|
21011
|
+
...args.duplexV2 ? { holdResumeMs: HOLD_RESUME_MS } : {}
|
|
21012
|
+
// v2 hosts stream the user's partials (userActive) — v1 hosts don't
|
|
20694
21013
|
});
|
|
20695
21014
|
if (args.voiceSystemPrompt) dx.voice.options.systemPrompt += "\n\n" + args.voiceSystemPrompt;
|
|
20696
21015
|
let scTurn = 0;
|
|
@@ -20718,9 +21037,11 @@ async function duplexServe(args, ai, cfg, cwd) {
|
|
|
20718
21037
|
}
|
|
20719
21038
|
if (m.type === "barge") {
|
|
20720
21039
|
sc?.onBargeIn();
|
|
21040
|
+
dx.userActive();
|
|
20721
21041
|
activeTurn?.abort();
|
|
20722
21042
|
dx.parkInFlightDeliveries();
|
|
20723
21043
|
} else if (sc && channelInput(sc, m)) {
|
|
21044
|
+
if (m.type === "interim" && String(m.text ?? "").trim()) dx.userActive();
|
|
20724
21045
|
} else if (m.type === "cancel") out({ type: "task", kind: "cancel_result", id: m.task, message: dx.cancelTask(String(m.task)) });
|
|
20725
21046
|
else if (m.type === "exit") rl.close();
|
|
20726
21047
|
else if (m.type === "user" && typeof m.text === "string" && m.text.trim()) {
|
|
@@ -20753,6 +21074,28 @@ async function duplexServe(args, ai, cfg, cwd) {
|
|
|
20753
21074
|
persist();
|
|
20754
21075
|
await closeMcp(mounted);
|
|
20755
21076
|
}
|
|
21077
|
+
async function channelServe(args, ai, cfg) {
|
|
21078
|
+
const out = (o) => process.stdout.write(JSON.stringify(o) + "\n");
|
|
21079
|
+
const { reflexModel, notes } = duplexModels({ voiceModel: args.voiceModel, reflexModel: cfg.reflexModel, thinkModel: false, actModel: String(args.model ?? cfg.model ?? ""), apiKeys: { ...cfg.apiKeys, ...apiKeysFromEnv() } });
|
|
21080
|
+
const model = reflexModel ?? new DuplexAgentOptions().reflexModel;
|
|
21081
|
+
const sc = makeSecondChannel(ai, model, { show: (text, kind) => out({ type: "channel", kind, text }) });
|
|
21082
|
+
out({ type: "ready", model, ...notes.length ? { notes } : {} });
|
|
21083
|
+
const rl = createInterface({ input: process.stdin });
|
|
21084
|
+
rl.on("line", (line) => {
|
|
21085
|
+
let m;
|
|
21086
|
+
try {
|
|
21087
|
+
m = JSON.parse(line);
|
|
21088
|
+
} catch {
|
|
21089
|
+
return;
|
|
21090
|
+
}
|
|
21091
|
+
if (channelInput(sc, m)) return;
|
|
21092
|
+
if (m.type === "user" && typeof m.text === "string") sc.onUserTurn(m.text);
|
|
21093
|
+
else if (m.type === "text" && typeof m.text === "string") sc.onMainTokens(m.text);
|
|
21094
|
+
else if (m.type === "barge") sc.onBargeIn();
|
|
21095
|
+
else if (m.type === "exit") rl.close();
|
|
21096
|
+
});
|
|
21097
|
+
await new Promise((r) => rl.on("close", () => r()));
|
|
21098
|
+
}
|
|
20756
21099
|
function readStdinSteers() {
|
|
20757
21100
|
const early = [];
|
|
20758
21101
|
let sink, got;
|
|
@@ -20922,6 +21265,10 @@ async function main() {
|
|
|
20922
21265
|
worktreeCleanup?.();
|
|
20923
21266
|
process.exit(ok ? 0 : res?.finishReason === "needs_input" ? 2 : 1);
|
|
20924
21267
|
}
|
|
21268
|
+
if (args.inputFormat && args.channelOnly) {
|
|
21269
|
+
await channelServe(args, ai, cfg);
|
|
21270
|
+
process.exit(0);
|
|
21271
|
+
}
|
|
20925
21272
|
if (args.inputFormat) {
|
|
20926
21273
|
await duplexServe(args, ai, cfg, cwd);
|
|
20927
21274
|
worktreeCleanup?.();
|