feat: integrate ApiPilot tools and add light-theme voice UI

This commit is contained in:
2026-10-05 08:25:29 -04:00
parent 413ba08c21
commit 4d7c7a23df
9 changed files with 6930 additions and 171 deletions
+467 -18
View File
@@ -53,6 +53,187 @@ function reportWsDebug(hypothesisId, location, msg, data = {}) {
void data;
}
// ---------------------------------------------------------------------------
// 🔎 WS DEBUG TRACER (sempre attivo, 2026-09-24)
// Banner + stile etichette condiviso per tutta la famiglia di log [S2S-WS:*].
// Non è più abilitato solo via localStorage.s2s.debug: gli eventi di vita
// (connect, close, errore) e l'handshake iniziale sono loggati always-on;
// i messaggi bulk (input_audio_buffer.append) vengono solo contati per non
// spammare la Console.
// ---------------------------------------------------------------------------
(function _wsDebugBanner() {
console.info(
"%c🟡 [S2S-WS] client caricato (ws/s2s-ws-client.js v2026-09-24 23:12) — handshake, stato, messaggi chiave e tool-call loggati; PREFETCH APIPILOT strumenti AD OGNI INIZIO SESSIONE + post-login phone (cache per-utente, cambio phone = refresh=true automatico; invalid. cache globale __s2sInvalidateApipilotCache(); anti-leak token; anti-spam audio).",
"background:#fef3c7;color:#92400e;font-weight:700;padding:4px 8px;border-radius:4px;"
);
})();
/** @param {number} status 0=connect,1=ok,2=warn,3=err @param {string} tag @param {string} msg @param {unknown[]} extra */
function _wsLog(status, tag, msg, ...extra) {
const palette = [
["color:#1d4ed8;font-weight:700;", "background:#dbeafe;color:#1e3a8a;font-weight:800;padding:1px 6px;border-radius:3px;"], // CONNECT info
["color:#047857;font-weight:700;", "background:#d1fae5;color:#065f46;font-weight:800;padding:1px 6px;border-radius:3px;"], // OK
["color:#b45309;font-weight:700;", "background:#fef3c7;color:#92400e;font-weight:800;padding:1px 6px;border-radius:3px;"], // WARN
["color:#b91c1c;font-weight:700;", "background:#fee2e2;color:#7f1d1d;font-weight:800;padding:1px 6px;border-radius:3px;"], // ERR
];
const [labelFg, badgeBg] = palette[Math.max(0, Math.min(3, status))];
const badge = `%c[S2S-WS:${tag}]`;
console.log(`${badge}%c ${msg}`, badgeBg, labelFg, ...extra);
}
/** Contatore per audio chunk bulk, per evitare spam. */
let _wsMicChunkCount = 0;
let _wsMicChunkLastLogTs = 0;
// ---------------------------------------------------------------------------
// 🔧 APIPILOT PREFETCH AD INIZIO SESSIONE (2026-09-24)
// Ogni volta che s2s-ws-client avvia una connessione Realtime (metodo connect())
// viene scaldata la cache ApiPilot lato server chiamando /api/apipilot/tools.
// Cache TTL lato client 240 s (= TTL server 300 s meno 60 s margine).
// Il prefetch è FIRE-AND-FORGET: non blocca MAI l'handshake WS, non fallisce
// connect() anche se il proxy restituisce 5xx.
// ---------------------------------------------------------------------------
/** @type {{at:number, data:any, key:string|null}|null} */
let _apipilotClientCache = null;
const _APIPILOT_CLIENT_TTL_MS = 240_000; // 4 minuti
/** Contatore sessioni totali (per log), incrementato ad ogni connect(). */
let _wsSessionCounter = 0;
/** Chiave cache per utente: ultimi 8 cifre phoneNumber o fingerprint token. */
function _apipilotUserKey() {
try {
// @ts-ignore
const phone = (typeof window !== "undefined" && window.voceroUserPhoneNumber) || "";
if (phone && phone.length >= 8) return `P:${phone.slice(-8)}`;
// @ts-ignore
const tk = typeof window !== "undefined" && typeof window.__s2sDemoSessionToken === "string" ? window.__s2sDemoSessionToken : "";
if (tk && tk.length >= 6) return `T:${tk.slice(-6)}`;
return "anon";
} catch (_) { return "anon"; }
}
/** Invalida la cache ApiPilot lato client. Esporta globalmente per main.js (dopo login phone). */
function _invalidateApipilotClientCache() {
_apipilotClientCache = null;
console.info(
"%c🔧 [S2S-WS:APIPILOT] Cache client invalidata (nuovo login utente o refresh=true forzato)",
"background:#fde68a;color:#92400e;font-weight:700;padding:1px 6px;border-radius:3px;"
);
}
// Stesso identico marker e regole del lato server Python (server.py#L555-L556).
// Lo ripetiamo identico in JS per garantire:
// (1) stessa classifica dedup
// (2) stesso formato regole marker AI
// (3) dedup by marker stringa <<<APIPILOT_TOOLS_LIST_BEGIN>>> in effectiveReasoningInstructions
// (così non appendiamo due volte se sia server che client provano entrambi).
const APIPILOT_PASS1_LIST_MARKER_JS = "<<<APIPILOT_TOOLS_LIST_BEGIN>>>";
/**
* Restituisce il testo del blocco PASSATA-1 da iniettare nel prompt instructions LLM.
* FORMATO IDENTICO al Python _resolve_apipilot_reasoning_context_block(tools=[...]).
* - Inizio con APIPILOT_PASS1_LIST_MARKER_JS
* - Fine con <<<APIPILOT_TOOLS_LIST_END>>>
* - Lingua: SPAGNOLO (stesso di profile inject e istruzioni AI)
* - Cap description a 500 chars
* - Max 100 strumenti
* @param {any[]|null|undefined} tools Lista da cache oppure null (usa cache corrente)
* @returns {string} Blocco di testo PASSATA-1 (stringa vuota se lista vuota / nessuno strumento / nessuna cache)
*/
function buildApipilotPass1PromptBlock(tools) {
let list = Array.isArray(tools) ? tools : null;
if (!list) {
// Fallback: usa cache client locale corrente (se disponibile)
const c = _apipilotClientCache;
if (c && c.data && Array.isArray(c.data.tools)) list = c.data.tools;
}
if (!Array.isArray(list) || list.length === 0) return "";
const capped = list.slice(0, 100);
/** @type {string[]} */
const entries = [];
for (const t of capped) {
if (!t || typeof t !== "object") continue;
const name = typeof t.name === "string" ? t.name : "";
if (!name) continue;
const desc = typeof t.description === "string" ? t.description : "";
const params = (t.parameters && typeof t.parameters === "object") ? t.parameters : null;
const requiresAuth = Boolean(t.requires_auth);
const req = (params && Array.isArray(params.required))
? params.required.filter((x) => typeof x === "string")
: [];
let descCut = desc.slice(0, 500);
if (desc.length > 500) descCut += "…";
let snippet = `- name: ${name}\n requires_auth: ${requiresAuth ? "true" : "false"}`;
if (req.length) snippet += `\n required_parameters: [${req.slice(0, 40).join(", ")}]`;
if (descCut) snippet += `\n description: ${descCut}`;
entries.push(snippet);
}
if (!entries.length) return "";
console.warn("APIPILOT_PASS1_LIST_MARKER_JS", APIPILOT_PASS1_LIST_MARKER_JS);
const markers = (
`${APIPILOT_PASS1_LIST_MARKER_JS}\n` +
"### Herramientas dinámicas disponibles (ApiPilot AquaPortal)\n" +
"\n" +
"Cada herramienta tiene un campo `name` (identificador único, NO lo modifiques, " +
"NO camelCase, NO traducir) y un campo `description` que describe el caso de uso.\n" +
"\n" +
"REGLAS EJECUCIÓN OBLIGATORIAS:\n" +
"1. Si la petición DEL USUARIO (transcript) coincide claramente con la `description` " +
"de EXACTAMENTE una (1) herramienta de la lista inferior → DEBES emitir EXCLUSIVAMENTE " +
"una sola riga come PRIMERA línea de TU RESPUESTA, con questo formato sintattico:\n" +
' <<<APIPILOT_CALL:NAME=nome_herramienta,ARGUMENTS={"parametro1":"valor1","parametro2":"valor2"}>>>\n' +
"2. La parte ARGUMENTS={…} DEBE ser JSON válido (virgolette doppie, virgole, senza " +
"commenti). Includi SOLAMENTE i campi elencati in `required_parameters:` (se ci sono).\n" +
"3. Subito DOPO la riga marker NON aggiungere altro testo (niente spiegazioni, " +
"niente anticipazioni). Il motore eseguirà lo strumento e ti risomministrerà il " +
"risultato in una seconda chiamata.\n" +
"4. Se NESSUNA `description` corrisponde, NON emettere marker e rispondi normalmente.\n" +
"5. Se più di una descrizione corrisponde, scegli la più specifica e usa il marker.\n" +
"\n" +
"Lista herramientas (name / description):\n" +
`${entries.join("\n")}\n` +
"<<<APIPILOT_TOOLS_LIST_END>>>"
);
console.warn("markers", markers);
return markers;
}
/**
* Controlla se le istruzioni in input contengono già il marker PASS1 (dedup).
* @param {string|undefined|null} text
* @returns {boolean}
*/
function instructionsAlreadyHasApipilotPass1(text) {
return typeof text === "string" && text.includes(APIPILOT_PASS1_LIST_MARKER_JS);
}
/**
* Espone globalmente una funzione helper per:
* - main.js: costruire effectiveReasoningInstructions appendendo la lista.
* - altri moduli che volessero leggerla.
* Il check dedup è fatto automaticamente dal chiamante prima di appendere.
* @returns {{block: string, count: number, marker: string, alreadyPresent:(t:string)=>boolean}}
*/
function getApipilotPass1ContextInfo() {
const c = _apipilotClientCache;
const tools = (c && c.data && Array.isArray(c.data.tools)) ? c.data.tools : [];
const block = buildApipilotPass1PromptBlock(tools);
return {
block,
count: tools.length,
marker: APIPILOT_PASS1_LIST_MARKER_JS,
alreadyPresent: instructionsAlreadyHasApipilotPass1,
};
}
try {
if (typeof window !== "undefined") {
Object.defineProperty(window, "__s2sInvalidateApipilotCache", {
value: _invalidateApipilotClientCache,
writable: true, configurable: true,
});
Object.defineProperty(window, "__s2sGetApipilotPass1ContextInfo", {
value: getApipilotPass1ContextInfo,
writable: true, configurable: true,
});
}
} catch (_) { /* noop */ }
/**
* @typedef {Object} WsClientOptions
* @property {string} [sessionUrl] URL to POST for the session handshake (returns
@@ -275,6 +456,12 @@ export class S2sWsRealtimeClient extends EventTarget {
*/
async connect() {
if (this._ws) throw new Error("Already connected");
// Contatore sessioni totali: utile nel tracer per correlare gli eventi
// di [S2S-WS:*] a un numero di sessione crescente.
_wsSessionCounter += 1;
const sessionIdShort = `#${_wsSessionCounter}`;
this._sessionSeq = _wsSessionCounter;
_wsLog(0, "SESSION", `${sessionIdShort} handshake iniziato — status: creating-session`);
let connectUrl;
if (this._directUrl) {
@@ -293,6 +480,7 @@ export class S2sWsRealtimeClient extends EventTarget {
// keeps a freed slot from being spent on someone who stepped away, and the
// click is a fresh gesture (re-arms the AudioContext on iOS).
if (waited) {
_wsLog(1, "QUEUE", `${sessionIdShort} tuo turno — attesa click join per confermare slot`);
await this._awaitJoin(grant);
if (this._closed) throw _codedError("connect aborted", "aborted");
}
@@ -308,10 +496,101 @@ export class S2sWsRealtimeClient extends EventTarget {
this.options.micStream = await this._acquireMic();
}
// Spin up the AudioContext + worklets in parallel with the WS dial.
// Spin up the AudioContext + worklets in parallel with the WS dial,
// E IN PARALLELO scaldiamo ApiPilot (strumenti dinamici AquaPortal).
// Il prefetch è non bloccante: se fallisce, non fallisce connect().
const audioReady = this._setupAudio();
const wsReady = this._openWebSocket(connectUrl);
await Promise.all([audioReady, wsReady]);
const apipilotReady = this._prefetchApipilotTools(sessionIdShort);
await Promise.all([audioReady, wsReady, apipilotReady.catch(() => void 0)]);
_wsLog(1, "SESSION", `${sessionIdShort} connessione stabilita + audio pipeline + apipilot OK`);
}
/**
* 🔧 PRE-FETCH strumenti ApiPilot ad ogni inizio di sessione Realtime.
* - Proxy sul server: GET api/apipilot/tools?include_unauthorized=true
* - Il wrapper FETCH globale traccia come [FETCH:APIPILOT:TOOLS] in Console.
* - Cache TTL locale 240 s per evitare chiamate ridondanti se l'utente
* chiude e riapre sessioni in rapida successione (server TTL=300 s).
* - SE window.__s2sApipilot=false → salta subito.
* - MAI fallisce: sempre catch interno.
* @param {string} sessionIdShort Log tag "sessione #N"
* @returns {Promise<void>}
*/
async _prefetchApipilotTools(sessionIdShort) {
try {
// @ts-ignore: __s2sApipilot è settato da main.js dopo fetchConfig()
const enabled = typeof window !== "undefined" && !!window.__s2sApipilot;
if (!enabled) {
_wsLog(2, "APIPILOT", `${sessionIdShort} feature flag apipilot=false sul server — salto prefetch strumenti`);
return;
}
const currentUser = _apipilotUserKey();
const now = Date.now();
const cached = _apipilotClientCache;
// HIT cache solo se: (1) ancora fresca TTL (2) stesso utente key (non cambio phone/token tra sessioni)
if (cached && (now - cached.at <= _APIPILOT_CLIENT_TTL_MS) && cached.key === currentUser) {
const ageSec = Math.round((now - cached.at) / 1000);
const n =
cached.data && typeof cached.data.count === "number"
? cached.data.count
: Array.isArray(cached.data && cached.data.tools)
? cached.data.tools.length
: "?";
_wsLog(1, "APIPILOT", `${sessionIdShort} HIT cache client strumenti (user=${currentUser}, age=${ageSec}s, N=${n}, TTL=${Math.round(_APIPILOT_CLIENT_TTL_MS/1000)}s)`);
return;
}
// Propaga header x-demo-session se disponibile in window (per auth fingerprint lato server S2S).
/** @type {Record<string,string>} */
const extraHeaders = {};
try {
// @ts-ignore
if (typeof window.__s2sDemoSessionToken === "string" && window.__s2sDemoSessionToken.length > 4) {
// @ts-ignore
extraHeaders["x-demo-session"] = window.__s2sDemoSessionToken;
}
} catch (_) { /* noop */ }
// Usa refresh=true SE è cambiato l'utente (cache.key diversa o null) oppure la cache è stata invalidata globalmente.
const needRefresh = !cached || cached.key !== currentUser || (now - cached.at > _APIPILOT_CLIENT_TTL_MS);
const qsRefresh = needRefresh ? "&refresh=true" : "";
const t0 = performance.now();
_wsLog(0, "APIPILOT", `${sessionIdShort} MISS cache (user=${currentUser}, refresh=${needRefresh ? "Y" : "N"}) → GET api/apipilot/tools (proxy a S2S_APIPILOT_TOOLS_URL server)...`);
const res = await fetch(`api/apipilot/tools?include_unauthorized=true${qsRefresh}`, {
method: "GET",
cache: "no-store",
headers: Object.keys(extraHeaders).length ? extraHeaders : undefined,
});
const dt = performance.now() - t0;
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
const j = await res.json().catch(() => ({}));
const ok = j && j.success && j.data;
if (!ok) {
throw new Error(`envelope non corretto: success=${Boolean(j && j.success)}`);
}
const tools = Array.isArray(j.data.tools) ? j.data.tools : [];
const count = typeof j.data.count === "number" ? j.data.count : tools.length;
// Aggiorna cache locale (in caso di hit la prossima sessione non chiamerà).
_apipilotClientCache = { at: Date.now(), data: { count, tools: tools.slice(0, 100) }, key: currentUser };
const names = tools
.slice(0, 5)
.map((t) => (typeof t.name === "string" ? t.name : "?"))
.join(", ");
_wsLog(
1,
"APIPILOT",
`${sessionIdShort} READY strumenti APIPILOT caricati: N=${count}, t=${Math.round(dt)}ms, user=${currentUser} [primi 5: ${names || "(nessuno)"}]`
);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
_wsLog(
3,
"APIPILOT",
`${sessionIdShort} prefetch FALLITO: ${msg} — diagnostica lato server log [APIPILOT-DISCOVERY]`
);
// NON rilanciare: connect() deve comunque andare a buon fine.
}
}
/**
@@ -381,30 +660,40 @@ export class S2sWsRealtimeClient extends EventTarget {
async _postSession() {
const url = this._sessionUrl;
console.log("[ws] POST", url);
_wsLog(0, "SESSION", `POST /session → ${url}`);
const t0 = performance.now();
const response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: "{}",
});
const dur = (performance.now() - t0).toFixed(1);
if (response.status === 402) {
// The session proxy refused: today's per-tier time budget is spent. Surface
// it as a typed error so the UI shows the limit modal, not a crash.
const body = await response.json().catch(() => ({}));
_wsLog(3, "SESSION", `/session HTTP 402: limite giornaliero esaurito (tier=${body?.tier ?? "?"}) dur=${dur} ms`);
throw _codedError("Daily conversation limit reached", "limit", { tier: body?.tier });
}
if (response.status === 503) {
const body = await response.json().catch(() => ({}));
if (body?.state === "at_capacity") {
_wsLog(2, "QUEUE:FULL", `/session HTTP 503 state=at_capacity (coda piena) dur=${dur} ms`);
throw _codedError("The queue is full — try again shortly.", "queue-full");
}
_wsLog(3, "SESSION", `/session HTTP 503 (fallback sconosciuto) dur=${dur} ms`);
throw new Error("/session failed (503)");
}
if (!response.ok) {
const text = await response.text().catch(() => "");
_wsLog(3, "SESSION", `/session fallita HTTP ${response.status}: ${text.slice(0, 180)} dur=${dur} ms`);
throw new Error(`/session failed (${response.status}): ${text}`);
}
const json = await response.json();
if (json.state === "queued") {
_wsLog(
2,
"QUEUE:WAIT",
`/session → stato=queued queueId=${json.queue_id ?? "?"} position=${json.position ?? "?"} pollEvery=${json.poll_interval_s ?? "?"}s dur=${dur} ms`
);
return {
state: "queued",
queueId: json.queue_id,
@@ -412,7 +701,13 @@ export class S2sWsRealtimeClient extends EventTarget {
pollIntervalS: json.poll_interval_s,
};
}
return { state: "granted", grant: this._parseGrant(json) };
const grant = this._parseGrant(json);
_wsLog(
1,
"SESSION",
`/session OK → GRANT sessionId=${grant.sessionId?.slice(0, 10)}… pendingTimeout=${grant.pendingTimeoutS}s dur=${dur} ms`
);
return { state: "granted", grant };
}
/**
@@ -426,8 +721,15 @@ export class S2sWsRealtimeClient extends EventTarget {
const intervalMs = Math.max(1, ticket.pollIntervalS || 2) * 1000;
this._queueId = ticket.queueId;
this._emitQueue(ticket.position);
_wsLog(
2,
"QUEUE:POLL",
`Inizio poll coda queueId=${ticket.queueId.slice(0, 10)}… initialPos=${ticket.position} intervalMs=${intervalMs}`
);
let pollNum = 0;
while (true) {
pollNum += 1;
await this._queueSleep(intervalMs);
if (this._closed) throw _codedError("queue wait aborted", "aborted");
@@ -442,9 +744,11 @@ export class S2sWsRealtimeClient extends EventTarget {
if (response.status === 402) {
const body = await response.json().catch(() => ({}));
_wsLog(3, "QUEUE:LIMIT", `poll #${pollNum} HTTP 402 — limite giornaliero (tier=${body?.tier ?? "?"})`);
throw _codedError("Daily conversation limit reached", "limit", { tier: body?.tier });
}
if (response.status === 404) {
_wsLog(3, "QUEUE:EXPIRED", `poll #${pollNum} HTTP 404 — ticket scaduto queueId=${this._queueId.slice(0, 10)}…`);
throw _codedError("Queue timed out", "queue-expired");
}
if (!response.ok) continue; // 502/503 — transient, retry
@@ -452,10 +756,14 @@ export class S2sWsRealtimeClient extends EventTarget {
const json = await response.json().catch(() => null);
if (!json) continue;
if (json.state === "queued") {
if (this._debug || pollNum === 1 || pollNum % 10 === 0) {
_wsLog(2, "QUEUE:POLL", `poll #${pollNum} position=${json.position ?? "?"}`);
}
this._emitQueue(json.position);
continue;
}
// Reached the front and claimed a slot.
_wsLog(1, "QUEUE:FRONT", `poll #${pollNum} — RAGGIUNTA la prima posizione. Assegno slot compute.`);
this._queueId = "";
return this._parseGrant(json);
}
@@ -573,6 +881,23 @@ export class S2sWsRealtimeClient extends EventTarget {
/** @param {string} connectUrl */
_openWebSocket(connectUrl) {
return new Promise((resolve, reject) => {
// Non loggiamo l'URL completo (potrebbe contenere session_token in
// chiaro nella querystring). Mostriamo solo host + path + un hash della
// parte segreta per audit senza leak token.
let maskedUrl = connectUrl;
try {
const u = new URL(connectUrl, window.location.href);
const secretPortions = [];
u.searchParams.forEach((v, k) => {
const lower = k.toLowerCase();
const isSecret = /token|session|auth|jwt|secret|key/i.test(lower) || String(v).length > 16;
secretPortions.push(`${k}=${isSecret ? `✂️…${String(v).slice(-4)} (${String(v).length} chars)` : v}`);
});
maskedUrl = `${u.protocol}//${u.host}${u.pathname} ? ${secretPortions.join(" & ") || "(nessun query param)"}`;
} catch {
// ignore
}
_wsLog(0, "CONNECT", `new WebSocket() → ${maskedUrl}`);
const ws = new WebSocket(connectUrl);
ws.binaryType = "arraybuffer";
this._ws = ws;
@@ -580,11 +905,13 @@ export class S2sWsRealtimeClient extends EventTarget {
const onceOpen = () => {
ws.removeEventListener("open", onceOpen);
ws.removeEventListener("error", onceErr);
_wsLog(1, "CONNECT", `WebSocket APERTO su ${maskedUrl} readyState=${ws.readyState}`);
resolve();
};
const onceErr = (e) => {
ws.removeEventListener("open", onceOpen);
ws.removeEventListener("error", onceErr);
_wsLog(3, "CONNECT", `WebSocket IMPOSSIBILE da aprire: event.type=${e?.type ?? "error"} url=${maskedUrl}`);
reject(new Error(`WebSocket failed to open: ${e?.type ?? "error"}`));
};
ws.addEventListener("open", onceOpen);
@@ -594,6 +921,7 @@ export class S2sWsRealtimeClient extends EventTarget {
ws.addEventListener("close", (e) => this._onWsClose(e));
ws.addEventListener("error", (e) => {
console.error("[ws] socket error", e);
_wsLog(3, "ERR", `WebSocket error: event.type=${e?.type ?? "error"} wasClean=${"wasClean" in e ? String(/** @type {CloseEvent} */ (e).wasClean) : "?"}`, e);
});
});
}
@@ -647,9 +975,33 @@ export class S2sWsRealtimeClient extends EventTarget {
const type = event?.type;
if (typeof type !== "string") return;
// Always-on tracing for high-signal events (lifecycle, transcript/tool calls,
// errors). For bulk streams (audio/text deltas) we log only count / first
// + last so the console doesn't scroll into uselessness.
const isHighSignal = /^(session\.|error$|conversation\.item\.created|conversation\.item\.input_audio_transcription\.completed|response\.created|response\.output_item\.done|response\.audio_transcript\.done|response\.output_audio_transcript\.done|response\.done|response\.function_call_arguments\.done|rate_limits\.updated)/i.test(
type,
);
if (isHighSignal) {
const isError = type === "error";
const extra = type.startsWith("conversation.item.input_audio_transcription")
? ` item=${event.item_id} ci=${event.content_index} — ${String(event.delta ?? event.transcript ?? "").slice(0, 180)}`
: type.startsWith("response.")
? ` resp=${String(event.response_id ?? event.response?.id ?? "").slice(0, 10)} status=${event.response?.status ?? "-"} ${String(event.transcript ?? event.text ?? event.delta ?? "").slice(0, 200)}`
: type === "response.function_call_arguments.done"
? ` tool=${event.name} callId=${String(event.call_id ?? "").slice(0, 10)} argsPreview=${String(event.arguments ?? "").slice(0, 220)}`
: type === "error"
? ` code=${String(event.code ?? "-")} message=${String(event.message ?? "").slice(0, 200)}`
: type === "rate_limits.updated"
? ` ${String(JSON.stringify(event.rate_limits ?? [])).slice(0, 220)}`
: "";
_wsLog(isError ? 3 : (type.startsWith("response.") && /(text_done|audio_transcript\.done|output_item\.done|output_audio_transcript\.done|function_call_arguments)/i.test(type) ? 1 : 0),
"MSG",
`← ${type}${extra}`
);
}
// Opt-in event tracing for diagnosing turn/transcript issues. Enable with
// `localStorage.setItem("s2s.debug", "1")` in the browser console.
if (this._debug) {
if (this._debug && !isHighSignal) {
const extra = type.startsWith("conversation.item.input_audio_transcription")
? ` item=${event.item_id} ci=${event.content_index} ${event.delta ?? event.transcript ?? ""}`
: type.startsWith("response.")
@@ -1008,6 +1360,11 @@ export class S2sWsRealtimeClient extends EventTarget {
/** @param {CloseEvent} ev */
_onWsClose(ev) {
console.log("[ws] socket closed:", ev.code, ev.reason);
_wsLog(
(ev.code === 1000 || ev.code === 1001) ? 1 : (ev.code ? 3 : 2),
"CLOSE",
`WebSocket CHIUSO code=${ev.code ?? "?"} reason=${String(ev.reason ?? "(nessuna)").slice(0, 160)} wasClean=${ev.wasClean}`
);
if (this._status === "closed" || this._status === "error") return;
if (ev.code === 1000) {
this._setStatus("closed");
@@ -1037,6 +1394,38 @@ export class S2sWsRealtimeClient extends EventTarget {
output: { voice: this.options.voice },
},
};
// ───────────────────────────────────────────────────────────────────────────
// 2026-09-25 INIETTA API-PILOT HERRAMIENTAS DINÁMICAS (PASADA-1) nel session.update.
// 3 strati di assicurazione (in ordine di priorità crescente, dedup by marker):
// (A) main.js effectiveReasoningInstructions() ha già appeso la lista.
// (B) NOSTRO server.py proxy realtime ri-inietta (se routing lo attraversa).
// (C) QUI s2s-ws-client: ultimo punto di catch-all prima dell'invio al
// canale, GARANTISCE che la lista sia SEMPRE presente in istruzioni
// (anche se qualcuno fa updateSession() con text sovrascritto poco dopo).
{
const currentInstr = (typeof session.instructions === "string") ? session.instructions : "";
if (!instructionsAlreadyHasApipilotPass1(currentInstr)) {
try {
const info = typeof window !== "undefined" && typeof window.__s2sGetApipilotPass1ContextInfo === "function"
? window.__s2sGetApipilotPass1ContextInfo()
: null;
const blk = (info && typeof info.block === "string") ? info.block : buildApipilotPass1PromptBlock(null);
if (blk && blk.trim()) {
session.instructions = currentInstr
? `${currentInstr}\n\n${blk}`
: blk;
const lenBlk = blk.length;
const cntTools = (info && typeof info.count === "number") ? info.count : "?";
_wsLog(
1, "APIPILOT",
`_sendSessionUpdate: APPENDED PASS-1 instructions (N=${cntTools}, +${lenBlk} chars). origLen=${currentInstr.length} newLen=${session.instructions.length}`,
);
}
} catch (_) {
/* never-fail */
}
}
}
// Tools are declared here; the backend already accepts them in
// session.update and emits response.function_call_arguments.done when the
// model decides to call one. Only include the keys when we actually have
@@ -1049,8 +1438,9 @@ export class S2sWsRealtimeClient extends EventTarget {
reportWsDebug("B", "s2s-ws-client.js:_sendSessionUpdate", "sending initial session.update", {
toolCount: this._tools.length,
toolNames: this._tools.map((tool) => tool.name),
hasInstructions: !!this.options.instructions,
instructionLength: (this.options.instructions || "").length,
hasInstructions: !!session.instructions,
instructionLength: (session.instructions || "").length,
apipilotPass1Present: instructionsAlreadyHasApipilotPass1(session.instructions),
});
// #endregion
this._send({ type: "session.update", session });
@@ -1061,14 +1451,35 @@ export class S2sWsRealtimeClient extends EventTarget {
updateSession(patch) {
/** @type {Record<string, any>} */
const session = { type: "realtime" };
if (patch.instructions) session.instructions = patch.instructions;
if (patch.instructions) {
let instr = patch.instructions;
// ── 2026-09-25 catch-all apipilot PASS-1 anche per patch live. ──
if (typeof instr === "string" && !instructionsAlreadyHasApipilotPass1(instr)) {
try {
const info = typeof window !== "undefined" && typeof window.__s2sGetApipilotPass1ContextInfo === "function"
? window.__s2sGetApipilotPass1ContextInfo()
: null;
const blk = (info && typeof info.block === "string") ? info.block : buildApipilotPass1PromptBlock(null);
if (blk && blk.trim()) {
const origLen = instr.length;
instr = `${instr}\n\n${blk}`;
_wsLog(
1, "APIPILOT",
`updateSession: APPENDED PASS-1 on live patch (N=${(info && typeof info.count === "number") ? info.count : "?"}, +${blk.length} chars). origLen=${origLen} newLen=${instr.length}`,
);
}
} catch (_) { /* never-fail */ }
}
session.instructions = instr;
}
if (patch.voice) session.audio = { output: { voice: patch.voice } };
if (Object.keys(session).length > 1) {
// #region debug-point C:update-session
reportWsDebug("C", "s2s-ws-client.js:updateSession", "sending patch session.update", {
hasInstructions: !!patch.instructions,
instructionLength: (patch.instructions || "").length,
hasInstructions: !!session.instructions,
instructionLength: (session.instructions || "").length,
hasVoice: !!patch.voice,
apipilotPass1Present: typeof session.instructions === "string" && instructionsAlreadyHasApipilotPass1(session.instructions),
});
// #endregion
this._send({ type: "session.update", session });
@@ -1365,14 +1776,46 @@ export class S2sWsRealtimeClient extends EventTarget {
/** @param {Record<string, unknown>} event */
_send(event) {
if (!this._ws || this._ws.readyState !== WebSocket.OPEN) return;
// Anti-spam bulk-rate limiter per eventi audio (~25 al secondo).
// Loggiamo un resoconto ogni 250 chunk (circa 10s a 25Hz) invece di ogni singolo.
if (event.type === "input_audio_buffer.append") {
_wsMicChunkCount += 1;
const now = performance.now();
if (_wsMicChunkCount === 1 || now - _wsMicChunkLastLogTs >= 2500 || _wsMicChunkCount % 250 === 0) {
_wsMicChunkLastLogTs = now;
_wsLog(0, "SEND:BULK", `→ input_audio_buffer.append chunk #${_wsMicChunkCount} audio_len=${String(event.audio ?? "").length} chars (base64)`);
}
let payloadLength = 0;
let stringifyMs = 0;
let payload = "";
if (event.type === "conversation.item.create") {
const startedAt = performance.now();
payload = JSON.stringify(event);
stringifyMs = performance.now() - startedAt;
payloadLength = payload.length;
// #region debug-point A:conversation-item-create
reportWsDebug("A", "s2s-ws-client.js:_send", "sending conversation.item.create payload", {
payloadLength,
stringifyMs: Math.round(stringifyMs),
});
// #endregion
}
if (this._debug && (event.type === "session.update" || event.type === "conversation.item.create")) {
const preview = (payload || JSON.stringify(event)).slice(0, 400);
console.debug(`[Vocero WS OUT] type=${event.type} payload=${preview}`);
}
this._ws.send(payload || JSON.stringify(event));
return;
}
// Messaggi non bulk: log sempre [S2S-WS:SEND] con anteprima del payload.
let payloadLength = 0;
let stringifyMs = 0;
let payload = "";
const startedAt = performance.now();
payload = JSON.stringify(event);
stringifyMs = performance.now() - startedAt;
payloadLength = payload.length;
if (event.type === "conversation.item.create") {
const startedAt = performance.now();
payload = JSON.stringify(event);
stringifyMs = performance.now() - startedAt;
payloadLength = payload.length;
// #region debug-point A:conversation-item-create
reportWsDebug("A", "s2s-ws-client.js:_send", "sending conversation.item.create payload", {
payloadLength,
@@ -1380,11 +1823,17 @@ export class S2sWsRealtimeClient extends EventTarget {
});
// #endregion
}
const isToolish = /tool|function|response\.create/i.test(event.type || "");
const preview = payload.slice(0, isToolish ? 600 : 260);
_wsLog(
isToolish ? 1 : 0,
"SEND",
`→ ${String(event.type ?? "?")} payload=${payloadLength} bytes stringify=${Math.round(stringifyMs)} ms — ${preview}${payload.length > (isToolish ? 600 : 260) ? "…" : ""}`
);
if (this._debug && (event.type === "session.update" || event.type === "conversation.item.create")) {
const preview = (payload || JSON.stringify(event)).slice(0, 400);
console.debug(`[Vocero WS OUT] type=${event.type} payload=${preview}`);
console.debug(`[Vocero WS OUT] type=${event.type} payload=${payload.slice(0, 400)}`);
}
this._ws.send(payload || JSON.stringify(event));
this._ws.send(payload);
}
async close() {