@inetafrica/open-claudia 3.2.1 → 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,32 @@
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
+
14
+ ## v3.2.3 — No watch can end silently: every watch carries a timeout that fires as a wake-up
15
+
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.
17
+ - **Every new watch gets a hard expiry.** `watch-add` grows `--timeout <duration|ISO>` (90m, 6h, 1d, or an ISO datetime), default **2h**, stamped as `expiresAt` on the job. On the first poll tick at or past the deadline where the check *still* fails, the watch FIRES anyway: the woken prompt opens with `[Watch expired ...]` plus an explicit "the watched condition did NOT happen — this is a deadline wake-up, not a trigger" brief, and the chat advisory reads `⏰ Watch "label" timed out without triggering — waking anyway`. A check that passes exactly on the deadline tick still reports as a normal trigger.
18
+ - **Expiry ends the watch's window.** An expiry fire is one-shot even for `--repeat` watches (the woken agent re-arms if still wanted), and the expiry mark is persisted so a failed expiry fire retries with the same semantics across restarts — while a retry whose condition came true in the meantime reports as a real trigger, not an expiry. `watch-list` now shows each watch's expiry (or `EXPIRED (fires on next poll)`); the global `/watch off` master switch still pauses everything, expiry included, until re-enabled.
19
+ - **Backward compatible by choice.** Watches persisted before this release carry no `expiresAt` and keep their old poll-forever semantics rather than being retro-expired on upgrade (a pre-upgrade watch older than the default would otherwise fire the instant the bot restarts). `watch-list` flags these as `no expiry (pre-timeout watch)`.
20
+ - Regression coverage in `test-provider-scheduler.js`: future-expiry idle stays silent, lapsed-expiry fire (header, brief, advisory, removal despite `--repeat`), deadline-tick pass is a trigger, default and explicit expiry stamping, and static end-to-end wiring (scheduler default → loopback passthrough → CLI flag).
21
+
22
+ ## v3.2.2 — The chip thinks from first contact, keeps thinking through the tools, and the dedup gate actually runs
23
+
24
+ - **The dead minute before the chip appeared.** The typing heartbeat only started inside `executeAdmittedRun` — but a turn-bound message first crosses the enforcer's inbound triage (an LLM call), and voice notes add a media download plus STT before that. All of it ran with zero presence: on a slow triage the chat sat silent looking ignored, then "thinking" popped up long after the work had begun. The router now starts the heartbeat the moment a turn-bound message arrives (`startPreTurnTyping` in all five inbound handlers — text, voice, audio, photo, document), covering triage, download, and transcription. Non-turn traffic (wizards, vault prompts, credential pastes, commands) never shows a false chip because the start sits after those gates.
25
+ - **The chip decayed to a stale "typing" during tool work.** Any `tool_start` permanently cleared `thinkingPhase`, so from the first tool call onward every 4s tick sent a stateless `typing:start` — clients rendered "typing…" through minutes of tool execution, and "thinking" never came back within a turn. Terminal and text events (`text_delta`, `text_final`, `result`, `error`) still end the thinking phase, but `tool_start` now re-enters it: streaming text reads as "typing", tool stretches read as "thinking" — which is what they are. (Kazee mobile renders a stateless tick as "typing" and auto-clears 5s after the last tick, so the 4s cadence keeps the chip alive and the state accurate on old and new clients alike.)
26
+ - **Handoff without a scramble.** Two starts can now coexist safely: `startTypingHeartbeat` is a singleton per chat state — a new start supersedes the old interval, and every stopper is ownership-aware, acting only while its own interval is current. The router's pre-turn heartbeat hands off to the run's own heartbeat at `executeAdmittedRun` with no gap and no double interval; a stale stopper (a router `finally` after handoff, a superseded run) is a structural no-op. `/stop`, which clears the interval directly and thereby orphans the run's stopper, now emits the explicit `typing:stop` itself in both branches. `test-typing-heartbeat.js` joins the release chain: immediate thinking tick, supersede/ownership semantics, no orphaned timers across a full tick window, and the event-steering contract pinned.
27
+ - **The journal dedup gate never actually ran.** v3.2.0's write-time dedup adjudicator (A1) calls its utility with `purpose: "dedup"` — a purpose `utility-policy` never registered, so every invocation died at validation with `INVALID_UTILITY_PURPOSE` and failed open, silently, by design. Log-proven on the local install. "dedup" is now a first-class purpose riding the review pipeline: `MEMORY_PROVIDER` steers it and `PACK_DEDUP_MODEL` is honored as its model override (both the prefix and the legacy Claude alias, matching what the adjudicator already passes). Pinned in `test-utility-provider-policy.js`.
28
+ - **Not changed, on purpose:** the 5× duplicate voice notes from 2026-08-09 were client multi-tap creating *distinct* messages (fixed client-side in kazee-chat-mobile v3.25.23's optimistic-upload guard); the router's existing `isDuplicate` LRU already covers true socket redeliveries by message id, so no bot-side dedup was added.
29
+
3
30
  ## v3.2.1 — Speech as a general capability, sockets that survive rejection, and calls that greet back
4
31
 
5
32
  - **A rejected handshake no longer strands a bot forever.** socket.io-client permanently stops reconnecting when the *server* rejects a handshake (auth middleware `next(new Error())`): `connect_error` fires with `socket.active === false` and the manager never dials again. During the 2026-08-08 outage chat-central booted before Central's MongoDB and rejected every handshake for a few minutes — terminally stranding the whole bot fleet, pods "Running" but deaf until manually restarted. New `core/socket-reconnect.js` `wireTerminalReconnect()` watches for exactly that terminal state and drives a manual `connect()` with 2s→30s capped backoff; transport-level failures (where the manager retries on its own, `socket.active === true`) are left alone. A deliberate client-side `disconnect()` is intercepted — a wedged socket emits no event for it — and cancels retries until `connect()` re-arms them. Wired into both the kazee and spaces sockets; `test-socket-reconnect.js` joins the pretest chain.
package/bin/cli.js CHANGED
@@ -588,7 +588,7 @@ Background work (only inside an active bot-spawned task):
588
588
  open-claudia cron-add "<sched>" "<prompt>" Recurring; each fire uses your current provider/model selection
589
589
  open-claudia cron-list List all wakeups + crons on this channel
590
590
  open-claudia cron-remove <id> Remove a scheduled job
591
- open-claudia watch-add "<check>" "<prompt>" Silent monitor: poll a shell check, wake only when it exits 0
591
+ open-claudia watch-add "<check>" "<prompt>" Silent monitor: poll a shell check, wake when it exits 0 or on --timeout expiry (default 2h)
592
592
  open-claudia watch-list List watches on this channel + master switch state
593
593
  open-claudia watch-remove <id> Remove a watch
594
594
  open-claudia watch-on | watch-off Global master switch for all watches (owner)
@@ -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/bin/schedule.js CHANGED
@@ -16,11 +16,15 @@ Crons (recurring):
16
16
  open-claudia cron-remove <id> # remove by id from cron-list
17
17
 
18
18
  Watches (silent background monitor — polls a shell check, wakes you only on a hit):
19
- open-claudia watch-add "<check-cmd>" "<wake-prompt>" [--label "<label>"] [--every "<cron>"] [--repeat]
19
+ open-claudia watch-add "<check-cmd>" "<wake-prompt>" [--label "<label>"] [--every "<cron>"] [--repeat] [--timeout <when>]
20
20
  <check-cmd>: a shell command polled on a schedule (default every minute).
21
21
  exit 0 = condition met → wake the agent with <wake-prompt>.
22
22
  non-zero = still idle → nothing happens, nothing hits the chat.
23
23
  --repeat: keep polling after it fires (default: fire once, then remove).
24
+ --timeout: how long to keep polling — a duration (90m, 6h, 1d) or ISO
25
+ datetime. Default 2h. A watch that reaches its timeout without
26
+ the check ever passing FIRES anyway on the next poll, with the
27
+ prompt marked "Watch expired" — no watch can end silently.
24
28
  open-claudia watch-list # watches on this channel + master switch state
25
29
  open-claudia watch-remove <id> # remove a watch by id
26
30
  open-claudia watch-on / watch-off # global master switch for ALL watches (owner)
@@ -141,18 +145,29 @@ async function runWatchAdd(args) {
141
145
  const label = takeFlag(rest, "label");
142
146
  const schedule = takeFlag(rest, "every");
143
147
  const project = takeFlag(rest, "project");
148
+ const timeout = takeFlag(rest, "timeout");
144
149
  const repeatIdx = rest.indexOf("--repeat");
145
150
  const repeat = repeatIdx >= 0;
146
151
  if (repeat) rest.splice(repeatIdx, 1);
152
+ let expiresAt = null;
153
+ if (timeout !== null) {
154
+ expiresAt = parseWhen(timeout);
155
+ if (!expiresAt) {
156
+ console.error(`Could not parse --timeout: "${timeout}". Use a duration (90m, 6h, 1d) or ISO datetime.`);
157
+ process.exit(2);
158
+ }
159
+ }
147
160
  const check = rest[0];
148
161
  const prompt = rest.slice(1).join(" ");
149
- if (!check || !prompt) { console.error("Usage: watch-add \"<check-cmd>\" \"<wake-prompt>\" [--every \"<cron>\"] [--repeat]"); process.exit(2); }
162
+ if (!check || !prompt) { console.error("Usage: watch-add \"<check-cmd>\" \"<wake-prompt>\" [--every \"<cron>\"] [--repeat] [--timeout <when>]"); process.exit(2); }
150
163
  try {
151
164
  const request = { check, prompt, repeat, label: label || prompt.slice(0, 60) };
152
165
  if (schedule) request.schedule = schedule;
153
166
  if (project) request.project = project;
167
+ if (expiresAt) request.expiresAt = expiresAt;
154
168
  const res = await postJson("watch-add", request);
155
- console.log(`Added watch ${res.job.id} (${repeat ? "repeating" : "one-shot"}, polls "${res.job.schedule}"). Silent until the check passes.`);
169
+ const expiry = res.job.expiresAt ? `, expires ${new Date(res.job.expiresAt).toISOString()}` : "";
170
+ console.log(`Added watch ${res.job.id} (${repeat ? "repeating" : "one-shot"}, polls "${res.job.schedule}"${expiry}). Silent until the check passes; fires as "Watch expired" if the timeout lapses first.`);
156
171
  process.exit(0);
157
172
  } catch (e) { console.error(e.message); process.exit(1); }
158
173
  }
@@ -165,7 +180,12 @@ async function runWatchList() {
165
180
  if (list.length === 0) { console.log("No watches on this channel."); process.exit(0); }
166
181
  for (const w of list) {
167
182
  const mode = w.repeat ? "repeating" : "one-shot";
168
- console.log(`${w.id} ${mode} polls "${w.schedule}" check: ${w.check} → ${w.label || "(no label)"}`);
183
+ const expiry = Number(w.expiresAt) > 0
184
+ ? (Date.now() >= Number(w.expiresAt)
185
+ ? " EXPIRED (fires on next poll)"
186
+ : ` expires ${new Date(Number(w.expiresAt)).toISOString()}`)
187
+ : " no expiry (pre-timeout watch)";
188
+ console.log(`${w.id} ${mode} polls "${w.schedule}"${expiry} check: ${w.check} → ${w.label || "(no label)"}`);
169
189
  }
170
190
  process.exit(0);
171
191
  } catch (e) { console.error(e.message); process.exit(1); }
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,