iobroker.assistant 1.3.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -0
- package/admin/custom/.vite/manifest.json +62 -62
- package/admin/custom/assets/{Components-x2Dfv8qE.js → Components-D15eOzOp.js} +2 -2
- package/admin/custom/assets/{_virtual_mf-localSharedImportMap___mfe_internal__ConfigCustomAssistant__mf_owner__1-B4YbqhBb.js → _virtual_mf-localSharedImportMap___mfe_internal__ConfigCustomAssistant__mf_owner__1-C8qbMwoi.js} +1 -1
- package/admin/custom/assets/{_virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__163539842885036__loadShare___mf_0_emotion_mf_1_styled__loadShare__.js-IaosaymG.js → _virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__212234797318001__loadShare___mf_0_emotion_mf_1_styled__loadShare__.js-BWAFmFMj.js} +1 -1
- package/admin/custom/assets/{_virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__163539842885036__loadShare___mf_0_iobroker_mf_1_gui_mf_2_components__loadShare__.js-o4EU_Ut5.js → _virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__212234797318001__loadShare___mf_0_iobroker_mf_1_gui_mf_2_components__loadShare__.js-Bh5IbNwb.js} +1 -1
- package/admin/custom/assets/{_virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__163539842885036__loadShare__shared--be2hWbV.js → _virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__212234797318001__loadShare__shared-D8mhEL60.js} +1 -1
- package/admin/custom/assets/_virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__212234797318001__prebuild___mf_0_iobroker_mf_1_json_mf_2_config__prebuild__-T80cpMmm.js +122 -0
- package/admin/custom/assets/{_virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__163539842885036__prebuild__react_mf_1_jsx_mf_2_runtime__prebuild__-M61rjtmp.js → _virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__212234797318001__prebuild__react_mf_1_jsx_mf_2_runtime__prebuild__--SURtz9t.js} +1 -1
- package/admin/custom/assets/hostInit-DpzPHTOk.js +1 -0
- package/admin/custom/assets/{lib-DBbNtolG.js → lib-70DV4zKj.js} +1 -1
- package/admin/custom/assets/{mf-entry-bootstrap-0-7abd30d3.js → mf-entry-bootstrap-0-0119ee25.js} +2 -2
- package/admin/custom/assets/{pendingShares-U0D1MYK4.js → pendingShares-CKvyybtG.js} +1 -1
- package/admin/custom/customComponents.js +2 -2
- package/admin/custom/index.html +2 -2
- package/admin/custom/mf-manifest.json +1 -1
- package/admin/custom/mf-stats.json +1 -1
- package/admin/i18n/de.json +6 -1
- package/admin/i18n/en.json +6 -1
- package/admin/i18n/es.json +6 -1
- package/admin/i18n/fr.json +6 -1
- package/admin/i18n/it.json +6 -1
- package/admin/i18n/nl.json +6 -1
- package/admin/i18n/pl.json +6 -1
- package/admin/i18n/pt.json +6 -1
- package/admin/i18n/ru.json +6 -1
- package/admin/i18n/uk.json +6 -1
- package/admin/i18n/zh-cn.json +6 -1
- package/admin/jsonConfig.json +38 -4
- package/build/lib/nlu.js +14 -0
- package/build/lib/nlu.js.map +1 -1
- package/build/lib/piAgent.js +5 -0
- package/build/lib/piAgent.js.map +1 -1
- package/build/lib/voice/captures.js +87 -0
- package/build/lib/voice/captures.js.map +1 -0
- package/build/lib/voice/engines.js +1 -1
- package/build/lib/voice/engines.js.map +1 -1
- package/build/lib/voice/esphome.js +81 -5
- package/build/lib/voice/esphome.js.map +1 -1
- package/build/lib/voice/ttsCache.js +49 -1
- package/build/lib/voice/ttsCache.js.map +1 -1
- package/build/lib/voice/vad.js +14 -1
- package/build/lib/voice/vad.js.map +1 -1
- package/build/main.js +173 -63
- package/build/main.js.map +1 -1
- package/io-package.json +29 -27
- package/package.json +5 -5
- package/admin/custom/assets/_virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__163539842885036__prebuild___mf_0_iobroker_mf_1_json_mf_2_config__prebuild__-BsTz_EWE.js +0 -122
- package/admin/custom/assets/hostInit-Blh7a-Qe.js +0 -1
- /package/admin/custom/assets/{_virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__163539842885036__loadShare___mf_0_emotion_mf_1_react__loadShare__.js-BkH7fzDw.js → _virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__212234797318001__loadShare___mf_0_emotion_mf_1_react__loadShare__.js-BkH7fzDw.js} +0 -0
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"vad.js","sourceRoot":"","sources":["../../../src/lib/voice/vad.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * End-of-speech detection for satellites that stream microphone audio until they are told to stop.\n *\n * The Hannah/UDP protocol has the satellite decide when an utterance ended (`audio_end`), and Wyoming\n * clients send `audio-stop` themselves. ESPHome voice satellites do neither: they keep streaming until\n * the server sends `STT_VAD_END`/`STT_END`, so the decision has to happen here.\n *\n * This is a plain energy gate, not a neural VAD — good enough to close an utterance, and it costs\n * nothing per frame. Speech must first rise above `startLevel`; after that, `silenceMs` worth of frames\n * below `endLevel` ends it. `maxMs` is the hard stop for rooms that are simply loud.\n *\n * Fixed thresholds only work when the microphone level is known, and on a real satellite it is not.\n * Measured on a ThirdReality Voice & Music Assistant: the device plays a wake-acknowledgement chirp\n * into its own microphone that peaks at full scale (RMS ~16000, clipped), while the speech that follows\n * arrives at RMS 358–469 — below the 700 default for `startLevel` and right on the 400 for `endLevel`.\n * That breaks the gate twice over: `sawSpeech` is satisfied by the chirp rather than by any voice, so\n * the \"nothing but speech-free audio\" guard never fires, and half the real speech frames count as\n * silence, so the utterance can be cut off mid-sentence.\n *\n * Hence two opt-in knobs, both enabled by the ESPHome transport:\n *\n * - `skipMs` drops the leading chirp from the *analysis* (never from the audio, which still goes to STT\n * in full — a fast speaker may already be talking).\n * - `adaptive` derives the thresholds from a tracked noise floor instead of fixed levels. The floor\n * follows the quietest frame seen (instant down, very slow up), so it settles on room noise even when\n * someone starts talking immediately — which a mean or median over a calibration window would not.\n */\n\n/**\n * A buffer that may be a view onto any `ArrayBufferLike` — what `Buffer.subarray()` and\n * `Buffer.concat()` hand back. Plain `Buffer` (= `Buffer<ArrayBuffer>`) assigns to it, not the reverse.\n */\nexport type Bytes = Buffer<ArrayBufferLike>;\n\n/** Root-mean-square amplitude (0…32767) of a mono 16-bit signed LE PCM buffer. */\nexport function rms(pcm: Bytes): number {\n const samples = Math.floor(pcm.length / 2);\n if (!samples) {\n return 0;\n }\n let sum = 0;\n for (let i = 0; i < samples; i++) {\n const value = pcm.readInt16LE(i * 2);\n sum += value * value;\n }\n return Math.sqrt(sum / samples);\n}\n\nexport interface SpeechDetectorOptions {\n /** Sample rate of the incoming PCM (ESPHome satellites always capture at 16 kHz). */\n sampleRate?: number;\n /** Analysis window; 30 ms is the usual compromise between latency and stability. */\n frameMs?: number;\n /** RMS above which we consider speech to have started. Ignored when `adaptive` is on. */\n startLevel?: number;\n /** RMS below which a frame counts as silence. Ignored when `adaptive` is on. */\n endLevel?: number;\n /** Silence after speech that ends the utterance. */\n silenceMs?: number;\n /** Hard cap on one utterance, in case the room never falls quiet. */\n maxMs?: number;\n /**\n * Leading audio to leave out of the analysis — for devices that chirp into their own microphone\n * when the wake word fires. The audio itself is untouched; this only stops the chirp from being\n * mistaken for speech and from poisoning the noise floor.\n */\n skipMs?: number;\n /** Derive the thresholds from the tracked noise floor instead of using fixed levels. */\n adaptive?: boolean;\n /** Adaptive mode: `startLevel` is at least this, however quiet the room is. */\n minStartLevel?: number;\n /** Adaptive mode: `endLevel` is at least this. */\n minEndLevel?: number;\n /** Adaptive mode: speech threshold as a multiple of the noise floor. */\n startFactor?: number;\n /** Adaptive mode: silence threshold as a multiple of the noise floor. */\n endFactor?: number;\n /** Adaptive mode: settle the noise floor this long before speech may be declared. */\n warmupMs?: number;\n}\n\n/** Why an utterance ended: the speaker stopped, or we hit the hard cap. */\nexport type SpeechEnd = 'speech-end' | 'timeout';\n\n/**\n * How fast the tracked noise floor may follow a *rising* ambient level, per analysed frame (30 ms).\n * Fast enough that a genuinely noisy room is matched within about a second, slow enough that a voice\n * cannot pull the floor up to its own level before it has been recognised as speech.\n */\nconst NOISE_RISE = 0.02;\n\nexport class SpeechDetector {\n private readonly frameBytes: number;\n private readonly frameMs: number;\n private readonly fixedStart: number;\n private readonly fixedEnd: number;\n private readonly silenceMs: number;\n private readonly maxMs: number;\n private readonly skipMs: number;\n private readonly adaptive: boolean;\n private readonly minStartLevel: number;\n private readonly minEndLevel: number;\n private readonly startFactor: number;\n private readonly endFactor: number;\n private readonly warmupMs: number;\n\n private pending: Bytes = Buffer.alloc(0);\n private speechSeen = false;\n private silentMs = 0;\n /** Tracked room level in adaptive mode; null until the first analysed frame. */\n private floor: number | null = null;\n /** Audio seen so far *including* the skipped lead-in, in milliseconds. */\n private elapsedMs = 0;\n /** Audio analysed so far, in milliseconds (exposed for logging). */\n totalMs = 0;\n /** Loudest frame seen so far (exposed for logging — the usual clue when a mic is mis-configured). */\n peak = 0;\n\n constructor({\n sampleRate = 16000,\n frameMs = 30,\n startLevel = 700,\n endLevel = 400,\n silenceMs = 900,\n maxMs = 12000,\n skipMs = 0,\n adaptive = false,\n minStartLevel = 250,\n minEndLevel = 150,\n startFactor = 3.5,\n endFactor = 2,\n warmupMs = 150,\n }: SpeechDetectorOptions = {}) {\n this.frameBytes = Math.round((sampleRate * frameMs) / 1000) * 2;\n this.frameMs = frameMs;\n this.fixedStart = startLevel;\n this.fixedEnd = endLevel;\n this.silenceMs = silenceMs;\n this.maxMs = maxMs;\n this.skipMs = skipMs;\n this.adaptive = adaptive;\n this.minStartLevel = minStartLevel;\n this.minEndLevel = minEndLevel;\n this.startFactor = startFactor;\n this.endFactor = endFactor;\n this.warmupMs = warmupMs;\n }\n\n /** True once any frame crossed {@link startLevel} — an utterance that never did is room noise. */\n get sawSpeech(): boolean {\n return this.speechSeen;\n }\n\n /** Tracked room level (adaptive mode), for logging. 0 before the first analysed frame. */\n get noiseFloor(): number {\n return this.floor ?? 0;\n }\n\n /** Current speech threshold — fixed, or derived from the noise floor in adaptive mode. */\n get startLevel(): number {\n return this.adaptive ? Math.max(this.minStartLevel, (this.floor ?? 0) * this.startFactor) : this.fixedStart;\n }\n\n /** Current silence threshold. Never above {@link startLevel}, so the gate keeps its hysteresis. */\n get endLevel(): number {\n if (!this.adaptive) {\n return this.fixedEnd;\n }\n return Math.min(this.startLevel, Math.max(this.minEndLevel, (this.floor ?? 0) * this.endFactor));\n }\n\n /** Feed one audio chunk (any size). Returns why the utterance ended, or null to keep listening. */\n push(chunk: Bytes): SpeechEnd | null {\n this.pending = this.pending.length ? Buffer.concat([this.pending, chunk]) : chunk;\n let verdict: SpeechEnd | null = null;\n\n while (this.pending.length >= this.frameBytes) {\n const frame = this.pending.subarray(0, this.frameBytes);\n this.pending = this.pending.subarray(this.frameBytes);\n this.elapsedMs += this.frameMs;\n\n const level = rms(frame);\n if (level > this.peak) {\n this.peak = level;\n }\n\n // The device's own wake chirp: loud, clipped, and not speech. Leaving it out of the\n // analysis is what makes `sawSpeech` mean anything, and keeps the noise floor honest.\n if (this.elapsedMs <= this.skipMs) {\n continue;\n }\n this.totalMs += this.frameMs;\n\n if (this.adaptive) {\n // Three rules, each there to defeat a specific way of getting this wrong:\n // - start at zero, i.e. maximally sensitive. Seeding from the first frame instead\n // lets someone who talks straight after the wake word install their own voice as\n // the room's noise floor, which shuts the gate for good — the same trap an average\n // or median over a calibration window falls into.\n // - fall instantly, so a room that went quiet is believed at once.\n // - rise slowly, and not at all once speech has been recognised: otherwise a long\n // utterance drags the floor, and with it `endLevel`, up to its own level and cuts\n // itself off mid-sentence. Falling stays on, so the pauses between words still count.\n this.floor =\n this.floor === null\n ? 0\n : level < this.floor\n ? level\n : this.speechSeen\n ? this.floor\n : this.floor + (level - this.floor) * NOISE_RISE;\n if (this.totalMs < this.warmupMs) {\n continue; // let the floor settle before anything may count as speech\n }\n }\n\n if (!this.speechSeen) {\n if (level >= this.startLevel) {\n this.speechSeen = true;\n this.silentMs = 0;\n }\n } else if (level < this.endLevel) {\n this.silentMs += this.frameMs;\n if (this.silentMs >= this.silenceMs) {\n verdict ||= 'speech-end';\n }\n } else {\n this.silentMs = 0;\n }\n\n if (this.totalMs >= this.maxMs) {\n verdict ||= 'timeout';\n }\n }\n return verdict;\n }\n}\n"]}
|
|
1
|
+
{"version":3,"file":"vad.js","sourceRoot":"","sources":["../../../src/lib/voice/vad.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * End-of-speech detection for satellites that stream microphone audio until they are told to stop.\n *\n * The Hannah/UDP protocol has the satellite decide when an utterance ended (`audio_end`), and Wyoming\n * clients send `audio-stop` themselves. ESPHome voice satellites do neither: they keep streaming until\n * the server sends `STT_VAD_END`/`STT_END`, so the decision has to happen here.\n *\n * This is a plain energy gate, not a neural VAD — good enough to close an utterance, and it costs\n * nothing per frame. Speech must first rise above `startLevel`; after that, `silenceMs` worth of frames\n * below `endLevel` ends it. `maxMs` is the hard stop for rooms that are simply loud.\n *\n * Fixed thresholds only work when the microphone level is known, and on a real satellite it is not.\n * Measured on a ThirdReality Voice & Music Assistant: the device plays a wake-acknowledgement chirp\n * into its own microphone that peaks at full scale (RMS ~16000, clipped), while the speech that follows\n * arrives at RMS 358–469 — below the 700 default for `startLevel` and right on the 400 for `endLevel`.\n * That breaks the gate twice over: `sawSpeech` is satisfied by the chirp rather than by any voice, so\n * the \"nothing but speech-free audio\" guard never fires, and half the real speech frames count as\n * silence, so the utterance can be cut off mid-sentence.\n *\n * Hence two opt-in knobs, both enabled by the ESPHome transport:\n *\n * - `skipMs` drops the leading chirp from the *analysis* (never from the audio, which still goes to STT\n * in full — a fast speaker may already be talking).\n * - `adaptive` derives the thresholds from a tracked noise floor instead of fixed levels. The floor\n * follows the quietest frame seen (instant down, very slow up), so it settles on room noise even when\n * someone starts talking immediately — which a mean or median over a calibration window would not.\n */\n\n/**\n * A buffer that may be a view onto any `ArrayBufferLike` — what `Buffer.subarray()` and\n * `Buffer.concat()` hand back. Plain `Buffer` (= `Buffer<ArrayBuffer>`) assigns to it, not the reverse.\n */\nexport type Bytes = Buffer<ArrayBufferLike>;\n\n/** Root-mean-square amplitude (0…32767) of a mono 16-bit signed LE PCM buffer. */\nexport function rms(pcm: Bytes): number {\n const samples = Math.floor(pcm.length / 2);\n if (!samples) {\n return 0;\n }\n let sum = 0;\n for (let i = 0; i < samples; i++) {\n const value = pcm.readInt16LE(i * 2);\n sum += value * value;\n }\n return Math.sqrt(sum / samples);\n}\n\nexport interface SpeechDetectorOptions {\n /** Sample rate of the incoming PCM (ESPHome satellites always capture at 16 kHz). */\n sampleRate?: number;\n /** Analysis window; 30 ms is the usual compromise between latency and stability. */\n frameMs?: number;\n /** RMS above which we consider speech to have started. Ignored when `adaptive` is on. */\n startLevel?: number;\n /** RMS below which a frame counts as silence. Ignored when `adaptive` is on. */\n endLevel?: number;\n /** Silence after speech that ends the utterance. */\n silenceMs?: number;\n /** Hard cap on one utterance, in case the room never falls quiet. */\n maxMs?: number;\n /**\n * Give up when no speech has started this long after the stream began (the skipped lead-in included);\n * 0 = only {@link maxMs}. A false wake would otherwise keep the microphone open for the whole `maxMs`.\n */\n startTimeoutMs?: number;\n /**\n * Leading audio to leave out of the analysis — for devices that chirp into their own microphone\n * when the wake word fires. The audio itself is untouched; this only stops the chirp from being\n * mistaken for speech and from poisoning the noise floor.\n */\n skipMs?: number;\n /** Derive the thresholds from the tracked noise floor instead of using fixed levels. */\n adaptive?: boolean;\n /** Adaptive mode: `startLevel` is at least this, however quiet the room is. */\n minStartLevel?: number;\n /** Adaptive mode: `endLevel` is at least this. */\n minEndLevel?: number;\n /** Adaptive mode: speech threshold as a multiple of the noise floor. */\n startFactor?: number;\n /** Adaptive mode: silence threshold as a multiple of the noise floor. */\n endFactor?: number;\n /** Adaptive mode: settle the noise floor this long before speech may be declared. */\n warmupMs?: number;\n}\n\n/** Why an utterance ended: the speaker stopped, we hit the hard cap, or nobody started speaking at all. */\nexport type SpeechEnd = 'speech-end' | 'timeout' | 'no-speech';\n\n/**\n * How fast the tracked noise floor may follow a *rising* ambient level, per analysed frame (30 ms).\n * Fast enough that a genuinely noisy room is matched within about a second, slow enough that a voice\n * cannot pull the floor up to its own level before it has been recognised as speech.\n */\nconst NOISE_RISE = 0.02;\n\nexport class SpeechDetector {\n private readonly frameBytes: number;\n private readonly frameMs: number;\n private readonly fixedStart: number;\n private readonly fixedEnd: number;\n private readonly silenceMs: number;\n private readonly maxMs: number;\n private readonly startTimeoutMs: number;\n private readonly skipMs: number;\n private readonly adaptive: boolean;\n private readonly minStartLevel: number;\n private readonly minEndLevel: number;\n private readonly startFactor: number;\n private readonly endFactor: number;\n private readonly warmupMs: number;\n\n private pending: Bytes = Buffer.alloc(0);\n private speechSeen = false;\n private silentMs = 0;\n /** Tracked room level in adaptive mode; null until the first analysed frame. */\n private floor: number | null = null;\n /** Audio seen so far *including* the skipped lead-in, in milliseconds. */\n private elapsedMs = 0;\n /** Audio analysed so far, in milliseconds (exposed for logging). */\n totalMs = 0;\n /** Loudest frame seen so far (exposed for logging — the usual clue when a mic is mis-configured). */\n peak = 0;\n /**\n * Loudest frame after the skipped lead-in, i.e. without the wake chirp. Compared with {@link startLevel}\n * it tells whether a dropped utterance was silence or a voice that was simply too quiet for the gate.\n */\n analysedPeak = 0;\n\n constructor({\n sampleRate = 16000,\n frameMs = 30,\n startLevel = 700,\n endLevel = 400,\n silenceMs = 900,\n maxMs = 12000,\n startTimeoutMs = 0,\n skipMs = 0,\n adaptive = false,\n minStartLevel = 250,\n minEndLevel = 150,\n startFactor = 3.5,\n endFactor = 2,\n warmupMs = 150,\n }: SpeechDetectorOptions = {}) {\n this.frameBytes = Math.round((sampleRate * frameMs) / 1000) * 2;\n this.frameMs = frameMs;\n this.fixedStart = startLevel;\n this.fixedEnd = endLevel;\n this.silenceMs = silenceMs;\n this.maxMs = maxMs;\n this.startTimeoutMs = startTimeoutMs;\n this.skipMs = skipMs;\n this.adaptive = adaptive;\n this.minStartLevel = minStartLevel;\n this.minEndLevel = minEndLevel;\n this.startFactor = startFactor;\n this.endFactor = endFactor;\n this.warmupMs = warmupMs;\n }\n\n /** True once any frame crossed {@link startLevel} — an utterance that never did is room noise. */\n get sawSpeech(): boolean {\n return this.speechSeen;\n }\n\n /** Tracked room level (adaptive mode), for logging. 0 before the first analysed frame. */\n get noiseFloor(): number {\n return this.floor ?? 0;\n }\n\n /** Current speech threshold — fixed, or derived from the noise floor in adaptive mode. */\n get startLevel(): number {\n return this.adaptive ? Math.max(this.minStartLevel, (this.floor ?? 0) * this.startFactor) : this.fixedStart;\n }\n\n /** Current silence threshold. Never above {@link startLevel}, so the gate keeps its hysteresis. */\n get endLevel(): number {\n if (!this.adaptive) {\n return this.fixedEnd;\n }\n return Math.min(this.startLevel, Math.max(this.minEndLevel, (this.floor ?? 0) * this.endFactor));\n }\n\n /** Feed one audio chunk (any size). Returns why the utterance ended, or null to keep listening. */\n push(chunk: Bytes): SpeechEnd | null {\n this.pending = this.pending.length ? Buffer.concat([this.pending, chunk]) : chunk;\n let verdict: SpeechEnd | null = null;\n\n while (this.pending.length >= this.frameBytes) {\n const frame = this.pending.subarray(0, this.frameBytes);\n this.pending = this.pending.subarray(this.frameBytes);\n this.elapsedMs += this.frameMs;\n\n const level = rms(frame);\n if (level > this.peak) {\n this.peak = level;\n }\n\n // The device's own wake chirp: loud, clipped, and not speech. Leaving it out of the\n // analysis is what makes `sawSpeech` mean anything, and keeps the noise floor honest.\n if (this.elapsedMs <= this.skipMs) {\n continue;\n }\n this.totalMs += this.frameMs;\n if (level > this.analysedPeak) {\n this.analysedPeak = level;\n }\n\n if (this.adaptive) {\n // Three rules, each there to defeat a specific way of getting this wrong:\n // - start at zero, i.e. maximally sensitive. Seeding from the first frame instead\n // lets someone who talks straight after the wake word install their own voice as\n // the room's noise floor, which shuts the gate for good — the same trap an average\n // or median over a calibration window falls into.\n // - fall instantly, so a room that went quiet is believed at once.\n // - rise slowly, and not at all once speech has been recognised: otherwise a long\n // utterance drags the floor, and with it `endLevel`, up to its own level and cuts\n // itself off mid-sentence. Falling stays on, so the pauses between words still count.\n this.floor =\n this.floor === null\n ? 0\n : level < this.floor\n ? level\n : this.speechSeen\n ? this.floor\n : this.floor + (level - this.floor) * NOISE_RISE;\n if (this.totalMs < this.warmupMs) {\n continue; // let the floor settle before anything may count as speech\n }\n }\n\n if (!this.speechSeen) {\n if (level >= this.startLevel) {\n this.speechSeen = true;\n this.silentMs = 0;\n }\n } else if (level < this.endLevel) {\n this.silentMs += this.frameMs;\n if (this.silentMs >= this.silenceMs) {\n verdict ||= 'speech-end';\n }\n } else {\n this.silentMs = 0;\n }\n\n if (!this.speechSeen && this.startTimeoutMs > 0 && this.elapsedMs >= this.startTimeoutMs) {\n verdict ||= 'no-speech';\n }\n if (this.totalMs >= this.maxMs) {\n verdict ||= 'timeout';\n }\n }\n return verdict;\n }\n}\n"]}
|
package/build/main.js
CHANGED
|
@@ -67,6 +67,8 @@ const wakeWords_1 = require("./lib/voice/wakeWords");
|
|
|
67
67
|
const tone_1 = require("./lib/voice/tone");
|
|
68
68
|
const engines_1 = require("./lib/voice/engines");
|
|
69
69
|
const openrouter_1 = require("./lib/voice/openrouter");
|
|
70
|
+
const ttsCache_1 = require("./lib/voice/ttsCache");
|
|
71
|
+
const captures_1 = require("./lib/voice/captures");
|
|
70
72
|
const piAgent_1 = require("./lib/piAgent");
|
|
71
73
|
const systemAi_1 = require("./lib/systemAi");
|
|
72
74
|
/**
|
|
@@ -81,6 +83,12 @@ const WEATHER_CTX_TTL = 5 * 60 * 1000;
|
|
|
81
83
|
* that were added or renamed elsewhere.
|
|
82
84
|
*/
|
|
83
85
|
const NLU_DEVICES_TTL_MS = 60_000;
|
|
86
|
+
/**
|
|
87
|
+
* How long a question of the cloud model about a script keeps the next request of that source with the cloud model
|
|
88
|
+
* (the reply, e.g. "the kitchen light, every hour", says nothing about a script). As long as pi-agent waits for a
|
|
89
|
+
* confirmation by text.
|
|
90
|
+
*/
|
|
91
|
+
const SCRIPT_TOPIC_MS = 2 * 60_000;
|
|
84
92
|
/** Default for `localLlmTimeoutSec`: after this the local model is stopped and the next tier answers. */
|
|
85
93
|
const LOCAL_LLM_TIMEOUT_SEC = 15;
|
|
86
94
|
/**
|
|
@@ -93,6 +101,8 @@ const LOCAL_LLM_MAX_TOKENS = 256;
|
|
|
93
101
|
* the TTS cache at startup; everything else is cached on first use.
|
|
94
102
|
*/
|
|
95
103
|
/** Fields of the Voice tab the voice test takes from the unsaved form (everything an STT or TTS engine is built from). */
|
|
104
|
+
/** Transports whose answer is spoken by a satellite (and therefore cut at `ttsMaxChars`). */
|
|
105
|
+
const VOICE_TRANSPORTS = new Set(['esphome', 'udp', 'native', 'wyoming']);
|
|
96
106
|
const VOICE_FORM_FIELDS = [
|
|
97
107
|
'voiceLanguage',
|
|
98
108
|
'sttProvider',
|
|
@@ -644,6 +654,7 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
644
654
|
this.updateSatelliteControl(device, room, entity).catch(e => this.log.debug(`satellite control update failed: ${e.message}`));
|
|
645
655
|
},
|
|
646
656
|
expectsFollowUp: (answer, device) => this.followUpFor(device, answer),
|
|
657
|
+
onDiscarded: cfg.esphomeSaveCaptures ? capture => this.keepCapture(capture) : undefined,
|
|
647
658
|
externalWakeWords: () => [...this.customWakeWords.values()].map(w => ({
|
|
648
659
|
id: w.id,
|
|
649
660
|
phrase: w.phrase,
|
|
@@ -680,6 +691,7 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
680
691
|
openrouterSttModel: (cfg.openrouterSttModel || '').trim(),
|
|
681
692
|
openrouterTtsModel: (cfg.openrouterTtsModel || '').trim(),
|
|
682
693
|
decodeAudio: audio => this.decodeAudioBufferToPcm(audio),
|
|
694
|
+
ttsMaxChars: (0, ttsCache_1.parseTtsMaxChars)(cfg.ttsMaxChars),
|
|
683
695
|
};
|
|
684
696
|
}
|
|
685
697
|
/**
|
|
@@ -863,6 +875,8 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
863
875
|
const sttStart = Date.now();
|
|
864
876
|
const text = (await engines.stt.transcribe(pcm, rate, language, await this.buildSttHints())).trim();
|
|
865
877
|
if (!text) {
|
|
878
|
+
const ms = Math.round((pcm.length / 2 / rate) * 1000);
|
|
879
|
+
this.log.info(`Voice Q (${source}): (empty) — speech recognition returned no text for ${ms} ms of audio`);
|
|
866
880
|
return { text: '', answer: '' };
|
|
867
881
|
}
|
|
868
882
|
const answer = await this.answerVoice(text, source, {
|
|
@@ -1112,6 +1126,32 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
1112
1126
|
ack: true,
|
|
1113
1127
|
});
|
|
1114
1128
|
}
|
|
1129
|
+
/** Store for `esphomeSaveCaptures`, in the adapter's file storage (Admin → Files → assistant.N/captures). */
|
|
1130
|
+
captureStore = null;
|
|
1131
|
+
/** Keep a dropped ESPHome recording for diagnosis (`esphomeSaveCaptures`). Never blocks the voice pipeline. */
|
|
1132
|
+
keepCapture(capture) {
|
|
1133
|
+
this.captureStore ||= new captures_1.CaptureStore({
|
|
1134
|
+
write: async (path, data) => {
|
|
1135
|
+
await this.writeFileAsync(this.namespace, path, data);
|
|
1136
|
+
},
|
|
1137
|
+
list: async (dir) => {
|
|
1138
|
+
try {
|
|
1139
|
+
const entries = await this.readDirAsync(this.namespace, dir);
|
|
1140
|
+
return entries.filter(e => !e.isDir).map(e => e.file);
|
|
1141
|
+
}
|
|
1142
|
+
catch {
|
|
1143
|
+
return []; // nothing kept yet
|
|
1144
|
+
}
|
|
1145
|
+
},
|
|
1146
|
+
remove: async (path) => {
|
|
1147
|
+
await this.delFileAsync(this.namespace, path);
|
|
1148
|
+
},
|
|
1149
|
+
});
|
|
1150
|
+
this.captureStore
|
|
1151
|
+
.save(capture)
|
|
1152
|
+
.then(path => this.log.debug(`ESPHome ${capture.device}: recording kept as ${this.namespace}/${path}`))
|
|
1153
|
+
.catch(e => this.log.warn(`Could not keep the recording of ${capture.device}: ${e.message}`));
|
|
1154
|
+
}
|
|
1115
1155
|
/** Read the uploaded wake words (`<instance>/wakewords/<id>.json` + `.tflite`) once. */
|
|
1116
1156
|
loadCustomWakeWords() {
|
|
1117
1157
|
this.customWakeWordsLoaded ||= (async () => {
|
|
@@ -2667,7 +2707,8 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
2667
2707
|
* later turns have the full thread.
|
|
2668
2708
|
*/
|
|
2669
2709
|
answer(question, source = '', origin = {}) {
|
|
2670
|
-
|
|
2710
|
+
const voice = VOICE_TRANSPORTS.has(origin.transport || '');
|
|
2711
|
+
return this.requestOrigin.run({ source, room: origin.room || '', voice }, () => this.journal
|
|
2671
2712
|
? this.journal.run(question, source, origin, () => {
|
|
2672
2713
|
this.traceSpeechInput(question, origin);
|
|
2673
2714
|
return this.answerTracked(question, source);
|
|
@@ -2709,12 +2750,20 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
2709
2750
|
data: history,
|
|
2710
2751
|
});
|
|
2711
2752
|
}
|
|
2712
|
-
const
|
|
2753
|
+
const cloudOnly = this.cloudOnlyReason(question, source);
|
|
2754
|
+
const raw = await this.produceAnswer(question, history, source, cloudOnly);
|
|
2713
2755
|
// The LLM signals "I'm waiting for the user's reply" by appending the [[LISTEN]] control marker
|
|
2714
2756
|
// (see followUpHint). Strip it here so it's neither stored, shown nor spoken, and keep it per source:
|
|
2715
2757
|
// two satellites answered at the same time must not hand each other their mic-on.
|
|
2716
2758
|
const followUp = /\[\[LISTEN\]\]/i.test(raw);
|
|
2717
2759
|
this.followUps.set(source, followUp);
|
|
2760
|
+
// A script conversation goes on while the cloud model asks back: the reply goes to it as well.
|
|
2761
|
+
if (cloudOnly && followUp) {
|
|
2762
|
+
this.scriptTopics.set(source, Date.now());
|
|
2763
|
+
}
|
|
2764
|
+
else {
|
|
2765
|
+
this.scriptTopics.delete(source);
|
|
2766
|
+
}
|
|
2718
2767
|
if (followUp) {
|
|
2719
2768
|
this.journal?.note({ followUp: true });
|
|
2720
2769
|
}
|
|
@@ -2759,6 +2808,18 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
2759
2808
|
}
|
|
2760
2809
|
return 'If the request was only a command to switch devices on or off or to set them to a value, and everything worked, answer with the control marker [[DONE]] alone (a short beep plays instead of a sentence). For questions, errors or when you have something to tell, answer normally.';
|
|
2761
2810
|
}
|
|
2811
|
+
/**
|
|
2812
|
+
* Length instruction for a request that came in by voice: the answer is cut at `ttsMaxChars` before it is
|
|
2813
|
+
* spoken, so the model is asked to stay within it. Empty for text requests (chat, Telegram, scripts) and
|
|
2814
|
+
* when the limit is off.
|
|
2815
|
+
*/
|
|
2816
|
+
spokenLengthHint() {
|
|
2817
|
+
if (!this.config.voiceEnabled || !this.requestOrigin.getStore()?.voice) {
|
|
2818
|
+
return '';
|
|
2819
|
+
}
|
|
2820
|
+
const limit = (0, ttsCache_1.parseTtsMaxChars)(this.config.ttsMaxChars) ?? ttsCache_1.MAX_TTS_CHARS;
|
|
2821
|
+
return (0, ttsCache_1.spokenLengthHint)(limit, String(this.config.voiceLanguage || this.language || 'en'));
|
|
2822
|
+
}
|
|
2762
2823
|
/** What [[DONE]] says where no beep can be played */
|
|
2763
2824
|
doneText() {
|
|
2764
2825
|
const lang = String(this.config.voiceLanguage || this.language || 'en');
|
|
@@ -2766,6 +2827,24 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
2766
2827
|
}
|
|
2767
2828
|
/** Per source: whether its last {@link answer} asked the user something (LLM-signalled via [[LISTEN]]). */
|
|
2768
2829
|
followUps = new Map();
|
|
2830
|
+
/** Per source: when the cloud model last asked back about a script (see {@link cloudOnlyReason}). */
|
|
2831
|
+
scriptTopics = new Map();
|
|
2832
|
+
/**
|
|
2833
|
+
* Whether a request skips routines, YouTube, the rule NLU and the local model: when it is about a script, or answers
|
|
2834
|
+
* a question the cloud model asked about one. Writing or changing a script is work for the cloud model, and the
|
|
2835
|
+
* rules would read its details as commands of their own ("… that switches the kitchen light every hour" was answered
|
|
2836
|
+
* with the time). Without a cloud tier the local ones are all there is, so nothing is skipped.
|
|
2837
|
+
*/
|
|
2838
|
+
cloudOnlyReason(question, source) {
|
|
2839
|
+
if (!this.agent && !this.piAgent) {
|
|
2840
|
+
return null;
|
|
2841
|
+
}
|
|
2842
|
+
if ((0, nlu_1.mentionsScript)(question)) {
|
|
2843
|
+
return 'script';
|
|
2844
|
+
}
|
|
2845
|
+
const since = this.scriptTopics.get(source);
|
|
2846
|
+
return since !== undefined && Date.now() - since < SCRIPT_TOPIC_MS ? 'scriptReply' : null;
|
|
2847
|
+
}
|
|
2769
2848
|
/**
|
|
2770
2849
|
* Should the satellite behind `source` re-open its mic after this reply? Primarily the LLM's own
|
|
2771
2850
|
* [[LISTEN]] signal, with a question mark in the reply as the fallback (the NLU and the local tiers
|
|
@@ -2856,7 +2935,72 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
2856
2935
|
const time = new Intl.DateTimeFormat(locale, { hour: '2-digit', minute: '2-digit' }).format(now);
|
|
2857
2936
|
return lang === 'ru' ? `Сейчас ${time}.` : lang === 'de' ? `Es ist ${time} Uhr.` : `It's ${time}.`;
|
|
2858
2937
|
}
|
|
2859
|
-
|
|
2938
|
+
/**
|
|
2939
|
+
* The tiers that answer without a model: routines, YouTube and the rule NLU. Null when none of them understood the
|
|
2940
|
+
* request.
|
|
2941
|
+
*/
|
|
2942
|
+
async answerLocally(question, source) {
|
|
2943
|
+
// A routine is a macro the user wrote down, so it wins over everything that interprets: the NLU
|
|
2944
|
+
// would find "Licht" in "Gute Nacht, Licht aus" and do half of it, and the LLM would cost a
|
|
2945
|
+
// round-trip for a decision that has already been made.
|
|
2946
|
+
const routine = (0, routines_1.matchRoutine)(question, this.routines);
|
|
2947
|
+
if (routine) {
|
|
2948
|
+
this.log.info(`Routine '${routine.name}' triggered (source='${source}').`);
|
|
2949
|
+
this.journal?.note({ tier: 'routine', detail: routine.name });
|
|
2950
|
+
this.journal?.step({ stage: 'routine', title: `Routine "${routine.name}" matched`, data: routine });
|
|
2951
|
+
await this.runRoutine(routine, source);
|
|
2952
|
+
return routine.reply;
|
|
2953
|
+
}
|
|
2954
|
+
if (this.routines.length) {
|
|
2955
|
+
this.journal?.step({
|
|
2956
|
+
stage: 'routine',
|
|
2957
|
+
title: `No routine matched (${this.routines.length} configured)`,
|
|
2958
|
+
data: { phrases: this.routines.map(r => ({ name: r.name, phrases: r.phrases })) },
|
|
2959
|
+
});
|
|
2960
|
+
}
|
|
2961
|
+
// Tier 0: rule-based NLU (device commands) — fastest, offline, free.
|
|
2962
|
+
if (!this.config.useLocalNlu) {
|
|
2963
|
+
this.journal?.step({ stage: 'nlu', title: 'Rule-based NLU is switched off (useLocalNlu)' });
|
|
2964
|
+
return null;
|
|
2965
|
+
}
|
|
2966
|
+
// "Spiele den Kanal X ab" first: the device NLU would find the TV in "… auf dem Fernseher" and
|
|
2967
|
+
// answer with its power state.
|
|
2968
|
+
const yt = this.youtubePlayers.length ? (0, youtube_1.parseYouTubeCommand)(question, this.youtubePlayers) : null;
|
|
2969
|
+
if (yt) {
|
|
2970
|
+
this.log.info(`YouTube request understood locally: ${yt.kind} "${yt.query}" (source='${source}').`);
|
|
2971
|
+
this.journal?.note({ tier: 'nlu', detail: `youtube ${yt.kind}` });
|
|
2972
|
+
const start = Date.now();
|
|
2973
|
+
const res = await this.playYouTube(yt.query, yt.kind, yt.player);
|
|
2974
|
+
this.journal?.step({
|
|
2975
|
+
stage: 'youtube',
|
|
2976
|
+
title: `YouTube ${yt.kind} "${yt.query}"${yt.player ? ` on "${yt.player}"` : ''}`,
|
|
2977
|
+
ms: Date.now() - start,
|
|
2978
|
+
data: { parsed: yt, result: res },
|
|
2979
|
+
});
|
|
2980
|
+
return res.text;
|
|
2981
|
+
}
|
|
2982
|
+
if (this.youtubePlayers.length) {
|
|
2983
|
+
this.journal?.step({ stage: 'youtube', title: 'Not a YouTube command' });
|
|
2984
|
+
}
|
|
2985
|
+
try {
|
|
2986
|
+
const handled = await this.tryLocalNlu(question, source);
|
|
2987
|
+
if (handled !== null) {
|
|
2988
|
+
this.log.info(`Answered by rule-based NLU (source='${source}').`);
|
|
2989
|
+
return handled;
|
|
2990
|
+
}
|
|
2991
|
+
}
|
|
2992
|
+
catch (e) {
|
|
2993
|
+
this.log.debug(`NLU skipped: ${e.message}`);
|
|
2994
|
+
this.journal?.step({
|
|
2995
|
+
stage: 'nlu',
|
|
2996
|
+
level: 'warn',
|
|
2997
|
+
title: `NLU gave up, the request goes on: ${e.message}`,
|
|
2998
|
+
data: (0, journal_1.errorInfo)(e),
|
|
2999
|
+
});
|
|
3000
|
+
}
|
|
3001
|
+
return null;
|
|
3002
|
+
}
|
|
3003
|
+
async produceAnswer(question, history, source = '', cloudOnly = null) {
|
|
2860
3004
|
this.log.debug(`answer: source='${source}', context=${history.length} turn(s)`);
|
|
2861
3005
|
// A ringing timer/alarm is silenced by a stop word ("stop"/"halt"/"aufhören"/…) — checked first so
|
|
2862
3006
|
// it always wins over the normal pipeline while something is ringing.
|
|
@@ -2884,64 +3028,22 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
2884
3028
|
return '';
|
|
2885
3029
|
}
|
|
2886
3030
|
}
|
|
2887
|
-
|
|
2888
|
-
|
|
2889
|
-
|
|
2890
|
-
|
|
2891
|
-
|
|
2892
|
-
this.log.info(`Routine '${routine.name}' triggered (source='${source}').`);
|
|
2893
|
-
this.journal?.note({ tier: 'routine', detail: routine.name });
|
|
2894
|
-
this.journal?.step({ stage: 'routine', title: `Routine "${routine.name}" matched`, data: routine });
|
|
2895
|
-
await this.runRoutine(routine, source);
|
|
2896
|
-
return routine.reply;
|
|
2897
|
-
}
|
|
2898
|
-
if (this.routines.length) {
|
|
3031
|
+
if (cloudOnly) {
|
|
3032
|
+
const why = cloudOnly === 'script'
|
|
3033
|
+
? 'it is about a script'
|
|
3034
|
+
: 'it answers a question of the cloud model about a script';
|
|
3035
|
+
this.log.info(`Routines, NLU and local LLM skipped (source='${source}'): ${why}.`);
|
|
2899
3036
|
this.journal?.step({
|
|
2900
|
-
stage: '
|
|
2901
|
-
title: `
|
|
2902
|
-
data: {
|
|
3037
|
+
stage: 'nlu',
|
|
3038
|
+
title: `Routines, NLU and local LLM skipped: ${why}`,
|
|
3039
|
+
data: { reason: cloudOnly },
|
|
2903
3040
|
});
|
|
2904
3041
|
}
|
|
2905
|
-
// Tier 0: rule-based NLU (device commands) — fastest, offline, free.
|
|
2906
|
-
if (this.config.useLocalNlu) {
|
|
2907
|
-
// "Spiele den Kanal X ab" first: the device NLU would find the TV in "… auf dem Fernseher" and
|
|
2908
|
-
// answer with its power state.
|
|
2909
|
-
const yt = this.youtubePlayers.length ? (0, youtube_1.parseYouTubeCommand)(question, this.youtubePlayers) : null;
|
|
2910
|
-
if (yt) {
|
|
2911
|
-
this.log.info(`YouTube request understood locally: ${yt.kind} "${yt.query}" (source='${source}').`);
|
|
2912
|
-
this.journal?.note({ tier: 'nlu', detail: `youtube ${yt.kind}` });
|
|
2913
|
-
const start = Date.now();
|
|
2914
|
-
const res = await this.playYouTube(yt.query, yt.kind, yt.player);
|
|
2915
|
-
this.journal?.step({
|
|
2916
|
-
stage: 'youtube',
|
|
2917
|
-
title: `YouTube ${yt.kind} "${yt.query}"${yt.player ? ` on "${yt.player}"` : ''}`,
|
|
2918
|
-
ms: Date.now() - start,
|
|
2919
|
-
data: { parsed: yt, result: res },
|
|
2920
|
-
});
|
|
2921
|
-
return res.text;
|
|
2922
|
-
}
|
|
2923
|
-
if (this.youtubePlayers.length) {
|
|
2924
|
-
this.journal?.step({ stage: 'youtube', title: 'Not a YouTube command' });
|
|
2925
|
-
}
|
|
2926
|
-
try {
|
|
2927
|
-
const handled = await this.tryLocalNlu(question, source);
|
|
2928
|
-
if (handled !== null) {
|
|
2929
|
-
this.log.info(`Answered by rule-based NLU (source='${source}').`);
|
|
2930
|
-
return handled;
|
|
2931
|
-
}
|
|
2932
|
-
}
|
|
2933
|
-
catch (e) {
|
|
2934
|
-
this.log.debug(`NLU skipped: ${e.message}`);
|
|
2935
|
-
this.journal?.step({
|
|
2936
|
-
stage: 'nlu',
|
|
2937
|
-
level: 'warn',
|
|
2938
|
-
title: `NLU gave up, the request goes on: ${e.message}`,
|
|
2939
|
-
data: (0, journal_1.errorInfo)(e),
|
|
2940
|
-
});
|
|
2941
|
-
}
|
|
2942
|
-
}
|
|
2943
3042
|
else {
|
|
2944
|
-
|
|
3043
|
+
const local = await this.answerLocally(question, source);
|
|
3044
|
+
if (local !== null) {
|
|
3045
|
+
return local;
|
|
3046
|
+
}
|
|
2945
3047
|
}
|
|
2946
3048
|
// Current weather from the configured adapter — prepended to the user turn of BOTH LLM tiers so
|
|
2947
3049
|
// "how's the weather?" is answered from real data (the local model has no tools and would otherwise
|
|
@@ -2955,7 +3057,8 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
2955
3057
|
// so it would answer a follow-up like "yes" context-free and swallow it — let the cloud LLM (which has
|
|
2956
3058
|
// the thread and the tools) handle follow-ups instead. Without a cloud LLM there is nobody to hand
|
|
2957
3059
|
// follow-ups to, so the local model takes them too.
|
|
2958
|
-
|
|
3060
|
+
// A script request does not even fall back to it: a model without tools cannot write a script.
|
|
3061
|
+
const useLocal = !cloudOnly && !!(this.config.useLocalLlm && this.localLlm);
|
|
2959
3062
|
const cloud = !!(this.agent || this.piAgent);
|
|
2960
3063
|
const localFirst = useLocal && (!cloud || !!this.config.localLlmFirst);
|
|
2961
3064
|
if (localFirst && (!history.length || !cloud)) {
|
|
@@ -3003,9 +3106,12 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
3003
3106
|
const sys = parts.length ? parts.join('\n\n') : undefined;
|
|
3004
3107
|
this.log.info(`Cloud LLM [${this.agent.model || 'auto'}] (source='${source}', context=${history.length} turn(s)${mem ? ', memory' : ''}${weather ? ', weather' : ''}${this.presence?.configured ? ', presence' : ''}) — running tool loop…`);
|
|
3005
3108
|
// Prepend the current date/time (and the weather line, if a source is configured) to the user turn —
|
|
3006
|
-
// cache-safe, see buildTimeContext. The stored history keeps the untouched question.
|
|
3109
|
+
// cache-safe, see buildTimeContext. The stored history keeps the untouched question. A spoken request
|
|
3110
|
+
// also gets the length limit, after the question: in the system prompt it would bust the prompt cache
|
|
3111
|
+
// every time voice and text requests alternate.
|
|
3007
3112
|
const time = this.buildTimeContext();
|
|
3008
|
-
const
|
|
3113
|
+
const lengthHint = this.spokenLengthHint();
|
|
3114
|
+
const questionForLlm = [time, weather, question, lengthHint].filter(Boolean).join('\n\n');
|
|
3009
3115
|
this.journal?.note({
|
|
3010
3116
|
tier: 'cloud',
|
|
3011
3117
|
detail: `${this.llm?.provider || this.config.provider || 'openai'} · ${this.agent.model || 'auto'}`,
|
|
@@ -3015,11 +3121,12 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
3015
3121
|
const deviceLines = ctx ? ctx.split('\n').length - 1 : 0;
|
|
3016
3122
|
this.journal?.step({
|
|
3017
3123
|
stage: 'context',
|
|
3018
|
-
title: `Prompt: ${deviceLines} devices${mem ? ', memory' : ''}${weather ? ', weather' : ''}${followUp ? ', follow-up marker' : ''}`,
|
|
3124
|
+
title: `Prompt: ${deviceLines} devices${mem ? ', memory' : ''}${weather ? ', weather' : ''}${followUp ? ', follow-up marker' : ''}${lengthHint ? ', length limit' : ''}`,
|
|
3019
3125
|
data: {
|
|
3020
3126
|
systemPrompt: this.config.systemPrompt || '',
|
|
3021
3127
|
followUpHint: followUp || undefined,
|
|
3022
3128
|
doneHint: done || undefined,
|
|
3129
|
+
lengthHint: lengthHint || undefined,
|
|
3023
3130
|
memory: mem || undefined,
|
|
3024
3131
|
devices: deviceLines,
|
|
3025
3132
|
time,
|
|
@@ -3056,7 +3163,7 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
3056
3163
|
const followUp = this.config.voiceEnabled ? this.followUpHint() : '';
|
|
3057
3164
|
// The device list maps names to state ids, so pi-agent can act without searching first. It is the
|
|
3058
3165
|
// first to go when the instructions get too long — pi-agent can still search.
|
|
3059
|
-
const { text: instructions, dropped } = (0, piAgent_1.composeInstructions)([this.config.systemPrompt || '', followUp, this.doneHint()], [this.buildMemoryContext(), await this.buildDeviceContext()]);
|
|
3166
|
+
const { text: instructions, dropped } = (0, piAgent_1.composeInstructions)([this.config.systemPrompt || '', followUp, this.doneHint(), this.spokenLengthHint()], [this.buildMemoryContext(), await this.buildDeviceContext()]);
|
|
3060
3167
|
if (dropped) {
|
|
3061
3168
|
this.log.debug(`pi-agent: ${dropped} context block(s) left out, the instructions would be too long.`);
|
|
3062
3169
|
}
|
|
@@ -3066,7 +3173,10 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
3066
3173
|
this.log.info(`Cloud tier ${harness.instance} (source='${source}') — asking pi-agent…`);
|
|
3067
3174
|
this.journal?.note({ tier: 'cloud', detail: harness.instance });
|
|
3068
3175
|
try {
|
|
3069
|
-
|
|
3176
|
+
// pi-agent asks its questions to confirm a write ("… Say yes or no.") in this language, not in the system
|
|
3177
|
+
// language: a satellite listening in Russian does not hear a German "Ja"
|
|
3178
|
+
const language = String(this.config.voiceLanguage || this.language || '');
|
|
3179
|
+
return await harness.ask({ text, conversation: source, instructions, language });
|
|
3070
3180
|
}
|
|
3071
3181
|
catch (e) {
|
|
3072
3182
|
if (localFallback) {
|