@inetafrica/open-claudia 3.2.3 → 3.3.2
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 +20 -0
- package/bin/loopback-client.js +4 -0
- package/bot.js +10 -1
- package/channels/kazee/adapter.js +5 -0
- package/channels/kazee/call/audio.js +79 -0
- package/channels/kazee/call/manager.js +6 -0
- package/channels/kazee/call/session.js +356 -48
- package/channels/kazee/call/speech-client.js +38 -3
- package/core/actions.js +96 -4
- package/core/enforcer.js +52 -3
- package/core/handlers.js +46 -2
- package/core/intro-flow.js +81 -16
- package/core/loopback.js +37 -0
- package/core/media.js +71 -18
- package/core/pack-review.js +18 -0
- package/core/relationship.js +18 -2
- package/core/router.js +17 -7
- package/core/runner.js +7 -1
- package/core/speaker-attest.js +56 -0
- package/core/system-prompt.js +21 -4
- package/core/verify-contact.js +156 -0
- package/package.json +2 -2
- package/test-enforcer.js +31 -0
- package/test-kazee-call-manager.js +4 -1
- package/test-kazee-call-session.js +100 -4
- package/test-mandate-hygiene.js +23 -5
- package/test-provider-language.js +25 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v3.3.2 — Known people are recognized by transport id, approved owners stay owners, and a mandate can no longer land on the wrong note
|
|
4
|
+
|
|
5
|
+
- **Every known person got interrogated on a fresh Kazee DM.** The intro flow matched inbound chats only by `(adapter, channelId)` — but Kazee DMs key the envelope's channelId by the conversation *document* id while people handles store the sender's *user* id, so nothing ever matched: known teammates, and the owner on pods without `KAZEE_OWNER_USER_ID`, all got "who are you" (the Coders Africa incident, 2026-08-19/20). Now a sender whose transport user id already maps to a person is silently **adopted**: the DM channel is linked to their record, an auth entry is written, any stale pending intro is resolved, and `handleInbound` returns false so the router falls through and handles the turn normally. Adoption is gated on adapter-attested `surface: "dm"` — the Kazee adapter sets it only on a positive chat-meta read (`isGroupChat: false`); a failed meta read attests nothing (fail closed) — because auth.json standing is per-CHANNEL and adopting a room would hand every member the sender's standing. Display names are never matched; only the transport id, the same trust level as the env owner match.
|
|
6
|
+
- **Approving an owner onto a second channel demoted them there.** `applyApproval` hardcoded `isOwner: false` into the auth entry while `isChatOwner` reads exactly that file. The entry now mirrors `person.isOwner`, and a stale `false` entry from the bug era is upgraded in place on the next approval.
|
|
7
|
+
- **The mandate writer can no longer be steered by a display name.** `appendMandateLine`'s legacy fallback upserted by `personName` when the record carried no slug — a spoofable transport display name could land owner-authored security text on a same-named OTHER person's note (reachable from telegram-group externals, where there is no person record). Writes are now strictly slug-keyed: `entitySlug`, else the person REGISTRY (`personId` → `resolveEntitySlug`), else **refused** — never the name. The router's rule-capture path passes `personId` so per-person rules keep landing after the fallback's removal.
|
|
8
|
+
- **Stranded grants are surfaced the moment they bite.** The dual-file trap — a mandate granted on the name-obvious note (`rahil-malde.md`) while the enforcer reads the person's hashed record slug — made grants silently do nothing. Escalation cards now carry a ⚠️ hint naming the file holding the dead grant and pointing at Verify / Make-a-rule, whenever the speaker's live mandate is empty but a same-named sibling note (that no other person owns) holds one. Detection only: the stranded text is never applied and never quoted.
|
|
9
|
+
- **Router plumbing for the fall-through:** the cross-channel dedup check is memoized per envelope object, so the adopted first message isn't swallowed by `handleText`'s re-check while true redeliveries (fresh envelope, same message id) still dedup globally.
|
|
10
|
+
- Regression coverage: `test-intro-adoption.js` joins the chain (adoption unit + through-the-router integration, fail-closed attestation, approval owner-mirror and in-place repair); `test-mandate-hygiene.js` pins the name-fallback refusal and personId resolution; `test-kazee-group-gate.js` pins the dm attestation and its fail-closed absence; `test-enforcer.js` pins the stranded-mandate hint and that the stranded text never reaches the live mandate.
|
|
11
|
+
|
|
12
|
+
## v3.3.1 — Message length can no longer kill the voice: chunked pocket TTS (re-cut carrying unpublished v3.3.0)
|
|
13
|
+
|
|
14
|
+
- **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.
|
|
15
|
+
- **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.
|
|
16
|
+
- **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.
|
|
17
|
+
|
|
18
|
+
## v3.3.0 — Never published (build failed on a stale release-guard pin); content ships in v3.3.1
|
|
19
|
+
|
|
20
|
+
- **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.
|
|
21
|
+
- **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.
|
|
22
|
+
|
|
3
23
|
## v3.2.3 — No watch can end silently: every watch carries a timeout that fires as a wake-up
|
|
4
24
|
|
|
5
25
|
- **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.
|
package/bin/loopback-client.js
CHANGED
|
@@ -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) =>
|
|
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
|
|
|
@@ -301,6 +301,11 @@ class KazeeAdapter {
|
|
|
301
301
|
}
|
|
302
302
|
envelope.engaged = true;
|
|
303
303
|
envelope.surface = "group";
|
|
304
|
+
} else if (meta) {
|
|
305
|
+
// Adapter-attested 1:1 conversation — required before intro-flow may
|
|
306
|
+
// auto-adopt the chat for a known sender. Set only on a positive meta
|
|
307
|
+
// read: an unresolvable chat stays unattested (fail closed).
|
|
308
|
+
envelope.surface = "dm";
|
|
304
309
|
}
|
|
305
310
|
}
|
|
306
311
|
|
|
@@ -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,
|