@inetafrica/open-claudia 3.2.3 → 3.3.1

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/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## v3.3.1 — Message length can no longer kill the voice: chunked pocket TTS (re-cut carrying unpublished v3.3.0)
4
+
5
+ - **Long voice notes died at 60 seconds of synthesis.** `pocketToVoice` sent the ENTIRE cleaned text to the speech gateway as one request under `AbortSignal.timeout(60000)`. A long reply blew that window, the pocket route returned null, and on pods the fallback chain has nowhere to go (no ElevenLabs key, no macOS `say`) — so `say` bounced with "no TTS route available" and agents improvised manual per-paragraph chunking (sales-bot, 2026-08-19). Long text is now sentence-packed into ≤450-char chunks (`chunkForTTS`; run-on sentences hard-split at word boundaries so nothing can recreate the timeout), each chunk synthesized in its own 120s window with one retry (a transient gateway blip no longer wastes the chunks already done), and the wavs concatenated via ffmpeg's concat demuxer into ONE ogg/opus. Short text keeps the exact single-request behavior. Benefits every consumer of `textToVoice`/`textToVoiceFile`: `say`, `tts`, and whole voice-note replies; the voice channel's streamed per-sentence path is untouched. Regression pinned in `test-speech-loopback.js`: multiple gateway calls each inside the pack limit, same voice across chunks, lossless reassembly, temp cleanup, and chunker unit coverage.
6
+ - **Why this is 3.3.1 and not 3.3.0:** the v3.3.0 release commit bumped `package.json` but not the release-guard pin (and skipped this changelog), so the tag's build job failed its own `npm test` — npm still served 3.2.3 and no fleet image was adopted. Never retag a failed version — re-cut as +1, pin repaired here.
7
+ - **Also ships (committed since 3.2.3):** the v3.3.0 verify-contact batch (below) and the Kazee calls batch — trickle ICE consumption (calls died with both legs stuck ICE-checking), streaming TTS playback (~3s faster to first byte) with per-voice greeting/goodbye PCM cache, hybrid word-guarded turn-end VAD, `call:ringing` on invite with publish deferred until the caller's feed is up, and werift ICE unhandled-rejection suppression.
8
+
9
+ ## v3.3.0 — Never published (build failed on a stale release-guard pin); content ships in v3.3.1
10
+
11
+ - **An unverified contact stays unverified until the owner says otherwise.** `ensureContact` keeps minting fresh unverified stubs (never link-by-name — names are claimable); the owner gets the explicit act that closes the loop: a "Verify contact…" button on escalation cards and `/people verify [link <slug>]`, both living ONLY on chat-envelope surfaces the agent cannot forge. Verification settles identity, never trust — mandates and relationship upgrades stay separate owner grants; retired stubs rename to `.superseded`, not deleted.
12
+ - **Three holes from the same audit, closed and pinned by tests:** `callerIsOwner` trusted the CLI's claimed canonicalUserId (forgeable env) → owner-gated loopback kinds now require a per-boot HMAC attest minted by the runner at spawn; `recent`-fetch let a guarded external turn read other channels' transcripts → default-deny; `relay-send` let a guarded turn message third parties as the bot → a guarded requester may relay only to the owner.
13
+
3
14
  ## v3.2.3 — No watch can end silently: every watch carries a timeout that fires as a wake-up
4
15
 
5
16
  - **The silent-failure hole in watches.** A watch whose check never exits 0 polled forever without a sound — if the watched thing hung, was misconfigured (the unauthenticated-`glab` watch from 2026-08-12 would never have fired), or simply never happened, the agent slept through it and the user had to notice the silence themselves. The working pattern was to pair every watch with a manual backup wakeup; that safety net is now built in.
@@ -43,6 +43,9 @@ function context() {
43
43
  adapterId: process.env.OC_CHANNEL_ADAPTER,
44
44
  userId: process.env.OC_CHANNEL_USER_ID || null,
45
45
  canonicalUserId: process.env.OC_CANONICAL_USER_ID || null,
46
+ // Runner-minted HMAC binding canonicalUserId to what the bot stamped for
47
+ // this turn; the loopback's owner gates verify it (see core/speaker-attest).
48
+ speakerAttest: process.env.OC_SPEAKER_ATTEST || null,
46
49
  provider: process.env.OC_PROVIDER || null,
47
50
  providerSettings,
48
51
  project: process.env.OC_PROJECT || null,
@@ -76,6 +79,7 @@ function postJson(kind, body) {
76
79
  adapter: ctx.adapterId,
77
80
  userId: ctx.userId,
78
81
  canonicalUserId: ctx.canonicalUserId,
82
+ speakerAttest: ctx.speakerAttest,
79
83
  provider: ctx.provider,
80
84
  providerSettings: ctx.providerSettings,
81
85
  project: requestedProject,
package/bot.js CHANGED
@@ -207,7 +207,16 @@ process.on("uncaughtException", (err) => {
207
207
  try { persist(); } catch (e) {}
208
208
  setTimeout(() => process.exit(1), 2000);
209
209
  });
210
- process.on("unhandledRejection", (reason) => notifyError("Unhandled rejection", reason));
210
+ process.on("unhandledRejection", (reason) => {
211
+ // werift's TURN/ICE client rejects STUN error responses (e.g. coturn 403s)
212
+ // uncaught; benign for the call itself, so keep them out of Telegram.
213
+ const stack = typeof reason?.stack === "string" ? reason.stack : "";
214
+ if (stack.includes("werift/lib/ice") || stack.includes("werift\\lib\\ice")) {
215
+ console.error("Unhandled rejection (werift ice, suppressed):", stack.split("\n").slice(0, 2).join(" | "));
216
+ return;
217
+ }
218
+ notifyError("Unhandled rejection", reason);
219
+ });
211
220
 
212
221
  // ── Update checker ─────────────────────────────────────────────────
213
222
 
@@ -120,6 +120,84 @@ class TurnDetector {
120
120
  }
121
121
  }
122
122
 
123
+ // A fixed threshold breaks in a noisy room: background keeps every frame
124
+ // "loud", the silence rule never fires, and the turn only ends at the 30s cap
125
+ // — 30 seconds of dead air. Tracking the running noise floor and sitting a
126
+ // fixed margin above it keeps the silence rule alive at any steady background
127
+ // level (jig-measured to hold to ~14dB SNR; below that only the recogniser's
128
+ // word timeline can end a turn, which the session layers on top).
129
+ class AdaptiveTurnDetector {
130
+ constructor({ marginDb = 12, windowFrames = 100, onsetFrames = 3, silenceMs = 700,
131
+ maxTurnMs = 30000, minThresholdDb = -50, bootstrapFrames = 40 } = {}) {
132
+ this.marginDb = marginDb;
133
+ this.windowFrames = windowFrames;
134
+ this.onsetFrames = onsetFrames;
135
+ this.minThresholdDb = minThresholdDb;
136
+ this.bootstrapFrames = bootstrapFrames;
137
+ this.silenceFrames = Math.ceil(silenceMs / FRAME_MS);
138
+ this.maxFrames = Math.ceil(maxTurnMs / FRAME_MS);
139
+ this.recent = [];
140
+ this.frames = 0;
141
+ // While the bot is speaking, device echo leaks back through the caller's
142
+ // mic; the session raises this so only clearly-louder audio counts.
143
+ this.boostTo = null;
144
+ this.reset();
145
+ }
146
+
147
+ reset() {
148
+ this.active = false;
149
+ this.loudFrames = 0;
150
+ this.quietFrames = 0;
151
+ this.turnFrames = 0;
152
+ }
153
+
154
+ _baseThreshold() {
155
+ const sorted = this.recent.filter(Number.isFinite).sort((a, b) => a - b);
156
+ if (sorted.length < 10) return this.minThresholdDb;
157
+ const floor = sorted[Math.floor(sorted.length * 0.1)];
158
+ return Math.max(this.minThresholdDb, floor + this.marginDb);
159
+ }
160
+
161
+ get thresholdDb() {
162
+ const base = this._baseThreshold();
163
+ return this.boostTo === null ? base : Math.max(base, this.boostTo);
164
+ }
165
+
166
+ push(pcm48k) {
167
+ const db = rmsDbfs(pcm48k);
168
+ this.frames += 1;
169
+ const base = this._baseThreshold();
170
+ // Bootstrap the floor from the opening of the call (the room is audible
171
+ // before anyone speaks), then keep it honest by sampling only frames that
172
+ // look like background — sampling the caller's own voice would raise the
173
+ // threshold above them and cut them off mid-word. The gate uses the
174
+ // un-boosted threshold so echo never pollutes the floor estimate.
175
+ if (this.frames <= this.bootstrapFrames || db < base) {
176
+ this.recent.push(db);
177
+ if (this.recent.length > this.windowFrames) this.recent.shift();
178
+ }
179
+ const loud = db >= (this.boostTo === null ? base : Math.max(base, this.boostTo));
180
+ if (!this.active) {
181
+ this.loudFrames = loud ? this.loudFrames + 1 : 0;
182
+ if (this.loudFrames >= this.onsetFrames) {
183
+ this.active = true;
184
+ this.quietFrames = 0;
185
+ this.turnFrames = this.loudFrames;
186
+ return { type: "start" };
187
+ }
188
+ return null;
189
+ }
190
+ this.turnFrames += 1;
191
+ this.quietFrames = loud ? 0 : this.quietFrames + 1;
192
+ if (this.quietFrames >= this.silenceFrames || this.turnFrames >= this.maxFrames) {
193
+ const reason = this.turnFrames >= this.maxFrames ? "max-duration" : "silence";
194
+ this.reset();
195
+ return { type: "commit", reason };
196
+ }
197
+ return null;
198
+ }
199
+ }
200
+
123
201
  module.exports = {
124
202
  FRAME_MS,
125
203
  STT_RATE,
@@ -127,6 +205,7 @@ module.exports = {
127
205
  WEBRTC_SAMPLES,
128
206
  OpusAudioCodec,
129
207
  TurnDetector,
208
+ AdaptiveTurnDetector,
130
209
  downsample48To16,
131
210
  rmsDbfs,
132
211
  upsampleTo48,
@@ -87,6 +87,12 @@ class KazeeCallManager {
87
87
  if (callType !== "audio") return reject("unsupported_media");
88
88
  if (this.session) return reject("busy");
89
89
 
90
+ // Human callee devices emit call:ringing as soon as they show the ring UI;
91
+ // chat-central relays it and the caller's client uses it to advance the
92
+ // status flow (calling -> ringing). Without it a web caller sits on
93
+ // "calling" with no feedback while we fetch ICE + join Janus (2026-08-16).
94
+ this.adapter._socket?.emit("call:ringing", { toUserId: callerId, chatId });
95
+
90
96
  const iceServers = await this._fetchIceServers();
91
97
  const session = new this.SessionImpl({
92
98
  roomId, chatId, callerId, botUserId: this.adapter.botUserId,