@inetafrica/open-claudia 3.1.28 → 3.2.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 +31 -0
- package/bin/cli.js +105 -0
- package/bin/entity.js +9 -0
- package/bin/pack.js +10 -0
- package/bin/recall-case.js +90 -0
- package/bin/remind.js +82 -0
- package/bin/tool.js +8 -3
- package/channels/kazee/adapter.js +37 -3
- package/channels/kazee/call/manager.js +128 -2
- package/channels/kazee/call/session.js +63 -25
- package/channels/kazee/socket.js +3 -0
- package/channels/spaces/socket.js +3 -0
- package/channels/telegram/adapter.js +9 -5
- package/core/apprenticeship.js +104 -0
- package/core/approvals.js +39 -0
- package/core/cost-report.js +140 -0
- package/core/dedup-adjudicator.js +250 -0
- package/core/dream.js +41 -8
- package/core/entities.js +27 -5
- package/core/loopback.js +51 -1
- package/core/media.js +58 -5
- package/core/pack-review.js +115 -12
- package/core/packs.js +102 -4
- package/core/prospective.js +135 -0
- package/core/recall-regressions.js +171 -0
- package/core/socket-reconnect.js +71 -0
- package/core/system-prompt.js +84 -6
- package/core/tools.js +77 -0
- package/package.json +3 -3
- package/test-fixtures/fake-agent-cli.js +5 -1
- package/test-kazee-call-manager.js +44 -1
- package/test-kazee-call-session.js +45 -2
- package/test-provider-language.js +11 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,36 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v3.2.1 — Speech as a general capability, sockets that survive rejection, and calls that greet back
|
|
4
|
+
|
|
5
|
+
- **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.
|
|
6
|
+
- **The bot can now place calls, both directions get a greeting, and the media leg works behind NAT.** Call-platform batch in the kazee channel, live-tested end to end on 2026-08-08:
|
|
7
|
+
- ICE servers now come from chat-central's `GET /webrtc/config` instead of a hardcoded STUN list, filtered to `stun:`/`turn:` URLs without `transport=tcp` (werift's ICE is UDP-only), falling back to defaults if the fetch fails. This fixes the zero-RTP calls where STUN alone couldn't punch through (diagnosed 2026-07-30).
|
|
8
|
+
- `outgoing({ chatId, toUserId })` places a bot-initiated call: the caller side owns room lifecycle, so the session creates the Janus room itself (mirroring the web client's params, tolerating 427 already-exists) and destroys it on close; `call:invite` goes out with a 60s ring timer — FCM's TTL is 60s and chat-central keeps ringing calls fresh for 90s, while the mobile app auto-ends its ring UI at 30s.
|
|
9
|
+
- That 30s device auto-end fires `call:end`, which previously tore down the room while a human was still walking to the phone — a late answer (44s in the live test) then joined a destroyed room and hung on "Connecting". `ended()` now ignores end events while the call is still ringing; the ring timer and `call:rejected` own that phase.
|
|
10
|
+
- Callers heard silence until they spoke first and read the call as dead. The session now speaks a greeting exactly once, when the first remote feed comes up — both directions ("Hey, it's Claudia…").
|
|
11
|
+
- Double-subscribe race fixed: the join response and the async publishers event both list the same feed, and while `attach()` was in flight the second frame passed the `has()` check and subscribed the feed twice, orphaning a PeerConnection (diagnosed 2026-07-30). The slot is now reserved before the first await.
|
|
12
|
+
- `call:accepted`/`call:rejected` are handled, and both call-test harnesses grew coverage (+45/+47 lines) for the outgoing flow, ring timeout, ignore-while-ringing, and greeting.
|
|
13
|
+
- **Received videos and mobile uploads no longer save as `.bin`.** Telegram messages without a fileName (videos especially) and Kazee Media docs from mobile uploads (which often carry no fileName at all) fell through to the `.bin` fallback, so an mp4 landed on disk unplayable. Extension derivation is now tiered: fileName ext → transport hint (Telegram's `file_path`, which carries the real extension; Kazee's blob-URL pathname) → mimeType map (16 common types) → per-media-type default (video→.mp4, audio→.m4a/.ogg) → `.bin` only as the true last resort. Kazee documents that keep their original name get the derived extension appended when the name has none.
|
|
14
|
+
- **TTS and STT become first-class agent capabilities, not just the voice-note reply path.** Fleet agents asked to produce a voice-over or transcribe a sent recording had nothing to reach for: they probed `open-claudia say --help` (which would have *spoken* "--help"), then tried direct gateway/vendor API calls and concluded "the key is missing" — correctly, because the provider subprocess is credential-stripped by design. Two new loopback verbs run in the credentialed bot process: `tts` (`open-claudia tts "<text>" --out <path> [--voice <id>]` — synthesize to a FILE without any chat send; extension picks the container, .ogg native / .mp3 / .wav transcoded; voice override reaches the gateway for cloned voices) and `transcribe` (`open-claudia transcribe <file>` — prints the transcript of any audio OR video file; `transcribeMedia` ffmpeg-normalizes to 16kHz mono wav first, which extracts video audio tracks and shrinks the gateway upload, then falls through the existing gateway → local-Whisper chain). No guardrail on `tts` because the artifact stays on local disk — the send verbs that could ship it carry their own R8 guard. A new "Speech services (TTS / STT)" system-prompt block — gated on real capability (`canSpeak()` / new `canTranscribe()`) — documents both plus `say`, and tells agents explicitly to never call the gateway or vendor APIs directly. `say`/`tts`/`transcribe` all answer `--help` now instead of synthesizing the flag. Regression chain: `test-speech-loopback.js` pins the loopback contract (empty text 400, no route 503, synth failure 500, relative/missing transcribe paths 400, empty transcript is a valid 200, auth 401) and the media mechanics (copy vs transcode by extension, mkdir -p, voice override in the gateway body, temp-wav cleanup, prompt-block presence). Verified live: a real gateway round-trip synthesized a sentence to mp3 and transcribed it back byte-identical.
|
|
15
|
+
|
|
16
|
+
## v3.2.0 — The employee batch: memory hygiene at write time, prospective memory, earned trust, and learning from the owner's judgment calls
|
|
17
|
+
|
|
18
|
+
Three approved plans land together — memory adoption A1-A4 (from the TencentDB-Agent-Memory review), codegraph Phase 1, and employee capabilities C1-C6 — plus the Kazee group speaker-identity fix. Two rules hold throughout: **report-only where trust is involved** (nothing here grants, acts, or loosens an approval rule by itself), and **no new standing context cost** (everything injects only when already-matched content fires, or lands in the nightly dream summary).
|
|
19
|
+
|
|
20
|
+
- **A1 — write-time dedup adjudicator, gated (the only hot-path item).** The post-turn reviewer's journal facts used to append unconditionally; duplicates accumulated all day for the one nightly pass that can lose things (merge) to clean up. Now cheap extractive recall checks each proposed fact against existing journal lines first. The gate keeps the hot path free: no candidates → zero LLM cost, store as proposed; exact-normalized duplicates auto-skip mechanically; only the fuzzy remainder goes to ONE batched low-tier call returning per-fact `store | update | merge | skip`. Fail-open everywhere — any error, timeout, or malformed judgement stores facts as proposed, because dedup failure must never make memory worse than append-only.
|
|
21
|
+
- **A2 — merges keep the absorbed packs' history.** Dream merges used to delete the absorbed pack's entire Journal (surviving only in the backup dir); a merged pack read younger and thinner than the history it represents. Absorbed Journal lines now carry into the survivor — normalized-exact dedup only (never fuzzy: similar-but-distinct events must both survive), date-ascending, the cap keeps newest and drops undated lines first.
|
|
22
|
+
- **A3 — substitutability stamps.** The reviewer self-rates each journal line 0-10 for how fully its wording substitutes for the turn's original words — a checkable claim about the text, not a guess about future importance. The score rides the stored line as a trailing `⟨s:N⟩` marker (announcements stay clean, near-dup similarity ignores it) and protects low-scored, verbatim-precious lines from the dream's journal collapse.
|
|
23
|
+
- **A4 — section budgets are enforced, not requested.** "Keep State under ~150 words" was prose; States drifted. LLM-edited sections now have mechanical ceilings: pack State trims at the cap with a visible cut marker + flag, entity Notes and persona sections trim at theirs, and the review prompt measures State against the target so over-budget turns the reviewer's job into an edit, not an append. Mandate (owner-set policy) and user-origin writes are never trimmed.
|
|
24
|
+
- **codegraph — code-structure queries as a reusable read-only tool (Phase 1).** Machine-side `codegraph` tool wrapping `@colbymchenry/codegraph`, pinned exactly 1.5.0 (native binaries from a solo author — upgrades are deliberate, reviewed events, never `^` drift), `CODEGRAPH_TELEMETRY=0` baked into every invocation, 12 read-only verbs with query verbs auto-syncing the index first. TOOL.md carries the graded trust model: `explore`/`node`/`search`/`files` are high-trust retrieval; `callers`/`impact`/`affected` are **advisory, positive-signal only** (verified misses: CJS namespace calls, some TS class-method sites) — grep remains the completeness check for destructive refactors. Two-week usage evaluation follows; MCP exposure was dropped against the token-efficiency goal.
|
|
25
|
+
- **C1 — prospective memory.** "Next time X comes up, mention Y" — reminders bound to a TOPIC (pack or entity), not a time; crons and watches already own time and condition. They fire as a `📌 Pending:` line when the target surfaces in recall injection or is opened with `pack show`/`entity show`, and are consumed ONLY at commit points — a dropped render can never eat a reminder. One-shot by default with a fired ledger; `--sticky` survives; the dream GCs expired and orphaned entries. New `open-claudia remind` CLI.
|
|
26
|
+
- **C2 — recall misses become permanent tests.** `open-claudia recall-case add "<query>" --expect <slug>` saves a case whenever a miss is corrected; the dream re-grades the suite nightly against the deterministic recall floor (matchPacks + matchEntities — the candidate stage every engine builds on) so the suite runs, and means the same thing, on nights the providers are down. Capped with oldest-first rotation; orphaned cases drop; pass-rate + delta land in the dream's memory-health section. "It feels degraded" is now a number.
|
|
27
|
+
- **C3 — skill certification: trust is earned per verb, and granting stays human.** Tools already log per-verb runs in state.json; a certification tier is now derived from that telemetry — `certified` at ≥5 clean runs with no recent failure, `probation` for 7 days after a failure (only clean runs SINCE the failure re-certify, read from history), `learning` otherwise. `tool show` renders the tier per verb, labeled informational — nothing in the run gate consults it. The dream proposes at most 2 certified gated-tier verbs per night as Always-allow candidates; read-only verbs need no grant, and no auto-grant exists, ever.
|
|
28
|
+
- **C4 — apprenticeship radar: it asks to be taught.** A deterministic 7-day transcript scan spots repeated manual work — the same ad-hoc command re-derived by the agent (inline code + `$`-lines; its own CLI excluded) or the owner asking for the same thing again — at ≥3 occurrences across ≥2 distinct days. The dream's introspection report offers at most 2 as `/learn` candidates with the counts as evidence. Report-only: `/learn` stays owner-triggered, and no evidence means no section, no standing cost.
|
|
29
|
+
- **C5 — denied-approval mining: trained by the owner's judgment calls.** Every Deny tap was training data we dropped. Recently DENIED approval records (never approvals — an approve confirms, it corrects nothing) now feed the post-turn reviewer as fenced DATA with explicit never-instructions framing, on owner-turn reviews only — deny content can never surface via an external speaker's turn. Each deny is fed exactly once (stamped after a parseable decision; parse failure re-feeds). Forge-proof by structure: a deny-sourced lesson's trigger must cite a real record with status denied — the model cannot invent a refusal, and an approved id confers nothing.
|
|
30
|
+
- **C6 — weekly cost self-report.** The dream summary gains a cost section: rolling 7-day totals over the existing per-turn usage log, top burns by project, week-over-week delta, and ONE concrete suggested cut picked by deterministic heuristics. Pure arithmetic — no model call, no new logging surface.
|
|
31
|
+
- **Kazee groups: the Speaker block now knows the env-configured owner.** The prompt's Speaker block resolved identity ONLY through `people.findByHandle`, so an env-configured owner (`KAZEE_OWNER_USER_ID` — no people record, because auto-contact skips owners by design) and any not-yet-recorded group member spoke anonymously, and in group chats the model refused to attribute their messages (the "Ok go" incident, 2026-08-03). The block now falls back to the canonical owner check and the transport-reported display name — without a name ever conferring owner standing (R1 intact).
|
|
32
|
+
- **Eleven new release-chain tests** (chain now 67): journal union, substitutability stamps, section budgets, dedup adjudicator, prospective memory, recall regressions, cost report, speaker context, approval mining, tool certification, apprenticeship radar. The 360° dangling-pathway pass found and closed one real gap pre-release: the C5 deny-feed originally fired on ALL reviewed turns, including external speakers' — now owner-gated with tests proving external turns see nothing and records stay un-mined.
|
|
33
|
+
|
|
3
34
|
## v3.1.28 — The Telegram reboot loop was self-inflicted: the heal kept tearing down its own recovery
|
|
4
35
|
|
|
5
36
|
- **Symptom.** v3.1.27 shipped to all four Telegram pods and the loop continued (euvy: 6 restarts in 3h). Its fix was real but addressed a different failure — the dominant one never enters `_enterOutagePause` at all (zero "Telegram unreachable" lines in the crash logs). The decisive observation was that the bot reproduced it identically on a laptop — 155 self-exits in a local `bot.log` — which retired the cluster-network and conntrack theories outright. The shape is bursts: healthy for 2.7–4.2h, then ~10 minutes of failed heals, then `process.exit(1)`, repeat.
|
package/bin/cli.js
CHANGED
|
@@ -278,6 +278,11 @@ switch (command) {
|
|
|
278
278
|
}
|
|
279
279
|
|
|
280
280
|
case "say": {
|
|
281
|
+
// Agents probe with --help before speaking; don't synthesize the flag.
|
|
282
|
+
if (args[1] === "--help" || args[1] === "-h") {
|
|
283
|
+
console.log('Usage: open-claudia say "<text>"\nSynthesize the text (TTS) and send it as a voice note to the current chat.\nTo synthesize to a file instead (voice-overs), use: open-claudia tts "<text>" --out <path>');
|
|
284
|
+
process.exit(0);
|
|
285
|
+
}
|
|
281
286
|
const text = args.slice(1).join(" ").trim();
|
|
282
287
|
if (!text) {
|
|
283
288
|
console.error('Usage: open-claudia say "<text>"');
|
|
@@ -299,6 +304,90 @@ switch (command) {
|
|
|
299
304
|
break;
|
|
300
305
|
}
|
|
301
306
|
|
|
307
|
+
case "tts": {
|
|
308
|
+
if (args[1] === "--help" || args[1] === "-h") {
|
|
309
|
+
console.log('Usage: open-claudia tts "<text>" [--out <path>.ogg|.mp3|.wav] [--voice <id>]\nSynthesize speech to a FILE without sending it to chat (voice-overs, narration).\nThe extension picks the container; prints the final path. Omit --out for a temp .ogg.');
|
|
310
|
+
process.exit(0);
|
|
311
|
+
}
|
|
312
|
+
const rest = args.slice(1);
|
|
313
|
+
let out = null;
|
|
314
|
+
let voice = null;
|
|
315
|
+
const textParts = [];
|
|
316
|
+
for (let i = 0; i < rest.length; i++) {
|
|
317
|
+
if (rest[i] === "--out") out = rest[++i];
|
|
318
|
+
else if (rest[i] === "--voice") voice = rest[++i];
|
|
319
|
+
else textParts.push(rest[i]);
|
|
320
|
+
}
|
|
321
|
+
const text = textParts.join(" ").trim();
|
|
322
|
+
if (!text) {
|
|
323
|
+
console.error('Usage: open-claudia tts "<text>" [--out <path>.ogg|.mp3|.wav] [--voice <id>]');
|
|
324
|
+
process.exit(2);
|
|
325
|
+
}
|
|
326
|
+
(async () => {
|
|
327
|
+
// Like `say`, synthesis happens in the credentialed bot process — this CLI
|
|
328
|
+
// has no speech token. The bot writes the file and returns its path.
|
|
329
|
+
const { postJson } = require("./loopback-client");
|
|
330
|
+
try {
|
|
331
|
+
const res = await postJson("tts", {
|
|
332
|
+
text,
|
|
333
|
+
out: out ? path.resolve(out) : undefined,
|
|
334
|
+
voice: voice || undefined,
|
|
335
|
+
});
|
|
336
|
+
console.log(res.path);
|
|
337
|
+
process.exit(0);
|
|
338
|
+
} catch (e) {
|
|
339
|
+
console.error(e.message);
|
|
340
|
+
process.exit(1);
|
|
341
|
+
}
|
|
342
|
+
})();
|
|
343
|
+
break;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
case "transcribe": {
|
|
347
|
+
if (args[1] === "--help" || args[1] === "-h") {
|
|
348
|
+
console.log("Usage: open-claudia transcribe <audio-or-video-file>\nSpeech-to-text: prints the transcript. Accepts audio (ogg/mp3/m4a/wav) and video\n(mp4/mkv — the audio track is extracted automatically).");
|
|
349
|
+
process.exit(0);
|
|
350
|
+
}
|
|
351
|
+
const filePath = String(args[1] || "").trim();
|
|
352
|
+
if (!filePath) {
|
|
353
|
+
console.error("Usage: open-claudia transcribe <audio-or-video-file>");
|
|
354
|
+
process.exit(2);
|
|
355
|
+
}
|
|
356
|
+
const abs = path.resolve(filePath);
|
|
357
|
+
if (!fs.existsSync(abs)) {
|
|
358
|
+
console.error(`File not found: ${abs}`);
|
|
359
|
+
process.exit(2);
|
|
360
|
+
}
|
|
361
|
+
(async () => {
|
|
362
|
+
const { postJson } = require("./loopback-client");
|
|
363
|
+
try {
|
|
364
|
+
const res = await postJson("transcribe", { path: abs });
|
|
365
|
+
console.log(res.text || "");
|
|
366
|
+
process.exit(0);
|
|
367
|
+
} catch (e) {
|
|
368
|
+
console.error(e.message);
|
|
369
|
+
process.exit(1);
|
|
370
|
+
}
|
|
371
|
+
})();
|
|
372
|
+
break;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
case "call": {
|
|
376
|
+
(async () => {
|
|
377
|
+
const { postJson } = require("./loopback-client");
|
|
378
|
+
try {
|
|
379
|
+
const toUserId = String(args[1] || "").trim();
|
|
380
|
+
const res = await postJson("call", toUserId ? { toUserId } : {});
|
|
381
|
+
console.log(`Calling: room=${res.roomId} chat=${res.chatId} to=${res.toUserId}`);
|
|
382
|
+
process.exit(0);
|
|
383
|
+
} catch (e) {
|
|
384
|
+
console.error(e.message);
|
|
385
|
+
process.exit(1);
|
|
386
|
+
}
|
|
387
|
+
})();
|
|
388
|
+
break;
|
|
389
|
+
}
|
|
390
|
+
|
|
302
391
|
case "tw":
|
|
303
392
|
case "transcript-window": {
|
|
304
393
|
require("./transcript-window").run(args.slice(1));
|
|
@@ -375,6 +464,16 @@ switch (command) {
|
|
|
375
464
|
break;
|
|
376
465
|
}
|
|
377
466
|
|
|
467
|
+
case "remind": {
|
|
468
|
+
require("./remind").run(args.slice(1));
|
|
469
|
+
break;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
case "recall-case": {
|
|
473
|
+
require("./recall-case").run(args.slice(1));
|
|
474
|
+
break;
|
|
475
|
+
}
|
|
476
|
+
|
|
378
477
|
case "lessons": {
|
|
379
478
|
require("./lessons").run(args.slice(1));
|
|
380
479
|
break;
|
|
@@ -464,6 +563,10 @@ Send tools (only work inside an active bot-spawned task):
|
|
|
464
563
|
open-claudia send-photo <path> [caption]
|
|
465
564
|
open-claudia send-voice <path>
|
|
466
565
|
open-claudia say "<text>" Synthesize the text (TTS) and send it as a voice note
|
|
566
|
+
open-claudia tts "<text>" --out <path> Synthesize to a FILE without sending (voice-overs,
|
|
567
|
+
narration; ext picks format; --voice <id> optional)
|
|
568
|
+
open-claudia transcribe <file> Speech-to-text: print the transcript of an audio/video file
|
|
569
|
+
open-claudia call [toUserId] Place a live voice call on the current channel (kazee; owner-only)
|
|
467
570
|
|
|
468
571
|
Memory tools:
|
|
469
572
|
open-claudia transcript-search <query> Ranked FTS5 search over indexed transcripts
|
|
@@ -473,6 +576,8 @@ Memory tools:
|
|
|
473
576
|
open-claudia pack list|show|match|archive|restore|archived Context packs: living topic docs (skills + memory)
|
|
474
577
|
open-claudia tool list|search|show|scaffold|add|set|note|edit|run|migrate|remove Reusable tools: git-tracked dirs (exec + TOOL.md + telemetry), risk-gated runs
|
|
475
578
|
open-claudia entity list|show|match|note Entity notes: people/places/projects memory
|
|
579
|
+
open-claudia remind "<note>" --pack <dir>|--entity <slug> Prospective memory: fires when that topic next comes up (remind list|remove <id>)
|
|
580
|
+
open-claudia recall-case add "<query>" --expect <slug> Memory regression suite: corrected recall misses become nightly-graded tests (list|run|remove)
|
|
476
581
|
open-claudia lessons list|add|remove|show Always-loaded learned rules (cross-cutting, promoted after a miss)
|
|
477
582
|
open-claudia ideas list|add|remove|show Self-improvement backlog (captured by the nightly dream)
|
|
478
583
|
open-claudia keyring list|get|set|remove Operational creds the agent can reuse (plaintext; vault for secrets)
|
package/bin/entity.js
CHANGED
|
@@ -31,6 +31,15 @@ function run(args) {
|
|
|
31
31
|
const e = entities.findEntity(rest.join(" "));
|
|
32
32
|
if (!e) { console.error(`No entity: ${rest.join(" ")}`); process.exitCode = 1; return; }
|
|
33
33
|
console.log(fs.readFileSync(path.join(entities.ENTITIES_DIR, e.slug + ".md"), "utf-8"));
|
|
34
|
+
// C1: topic-bound reminders fire when the target is explicitly opened.
|
|
35
|
+
try {
|
|
36
|
+
const prospective = require("../core/prospective");
|
|
37
|
+
const pending = prospective.pendingFor("entity", e.slug);
|
|
38
|
+
if (pending.length) {
|
|
39
|
+
console.log("\n" + prospective.renderPendingLines(pending));
|
|
40
|
+
prospective.fire(pending, "entity show");
|
|
41
|
+
}
|
|
42
|
+
} catch (err) { /* reminders optional */ }
|
|
34
43
|
break;
|
|
35
44
|
}
|
|
36
45
|
|
package/bin/pack.js
CHANGED
|
@@ -113,6 +113,16 @@ function run(args) {
|
|
|
113
113
|
for (const t of linked) console.log(`# ${t.name} — ${t.description || "(no description)"}`);
|
|
114
114
|
}
|
|
115
115
|
} catch (e) { /* tools optional */ }
|
|
116
|
+
// C1: topic-bound reminders also fire when their target is explicitly
|
|
117
|
+
// opened — the show output IS the surface, so consuming here is safe.
|
|
118
|
+
try {
|
|
119
|
+
const prospective = require("../core/prospective");
|
|
120
|
+
const pending = prospective.pendingFor("pack", p.dir);
|
|
121
|
+
if (pending.length) {
|
|
122
|
+
console.log("\n" + prospective.renderPendingLines(pending));
|
|
123
|
+
prospective.fire(pending, "pack show");
|
|
124
|
+
}
|
|
125
|
+
} catch (e) { /* reminders optional */ }
|
|
116
126
|
break;
|
|
117
127
|
}
|
|
118
128
|
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// CLI: memory regression suite — corrected recall misses become permanent
|
|
2
|
+
// test cases, re-graded nightly by the dream (deterministic, no LLM).
|
|
3
|
+
// open-claudia recall-case add "<query>" --expect <slug> [--expect <slug2>] [--source live-miss|manual] [--note "..."]
|
|
4
|
+
// open-claudia recall-case list
|
|
5
|
+
// open-claudia recall-case run [--cap N] — grade now and print per-case results
|
|
6
|
+
// open-claudia recall-case remove <id>
|
|
7
|
+
// Add a case the moment a recall miss gets corrected in chat — same moment a
|
|
8
|
+
// lesson forms. The query should be the user's original phrasing; the expected
|
|
9
|
+
// slug is the pack dir / entity slug that SHOULD have surfaced.
|
|
10
|
+
|
|
11
|
+
const regressions = require("../core/recall-regressions");
|
|
12
|
+
|
|
13
|
+
function usage() {
|
|
14
|
+
console.error('Usage: open-claudia recall-case add "<query>" --expect <slug> [--expect <slug2>] [--source live-miss|manual] [--note "..."]');
|
|
15
|
+
console.error(" open-claudia recall-case list | run [--cap N] | remove <id>");
|
|
16
|
+
process.exitCode = 1;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function run(args) {
|
|
20
|
+
const cmd = (args[0] || "").toLowerCase();
|
|
21
|
+
|
|
22
|
+
if (cmd === "" || cmd === "help" || cmd === "--help") return usage();
|
|
23
|
+
|
|
24
|
+
if (cmd === "list") {
|
|
25
|
+
const all = regressions.listCases();
|
|
26
|
+
if (all.length === 0) return console.log("No regression cases yet. Add one with: open-claudia recall-case add \"<query>\" --expect <slug>");
|
|
27
|
+
for (const c of all) {
|
|
28
|
+
const state = c.lastResult ? `${c.lastResult}${c.lastRunAt ? ` @ ${String(c.lastRunAt).slice(0, 10)}` : ""}` : "never run";
|
|
29
|
+
console.log(`${c.id} [${state}] (${c.source})\n query: ${c.query}\n expect: ${c.expectSlugs.join(", ")}${c.note ? `\n note: ${c.note}` : ""}`);
|
|
30
|
+
}
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (cmd === "remove") {
|
|
35
|
+
const id = args[1];
|
|
36
|
+
if (!id) return usage();
|
|
37
|
+
if (!regressions.removeCase(id)) { console.error(`No case: ${id} (open-claudia recall-case list)`); process.exitCode = 1; return; }
|
|
38
|
+
console.log(`Removed case ${id}.`);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (cmd === "run") {
|
|
43
|
+
let cap;
|
|
44
|
+
const capIdx = args.indexOf("--cap");
|
|
45
|
+
if (capIdx !== -1) cap = Math.max(1, parseInt(args[capIdx + 1], 10) || regressions.NIGHTLY_CAP);
|
|
46
|
+
const before = regressions.listCases();
|
|
47
|
+
if (before.length === 0) return console.log("No regression cases to run.");
|
|
48
|
+
const res = regressions.runSuite(cap ? { cap } : {});
|
|
49
|
+
for (const c of regressions.listCases()) {
|
|
50
|
+
if (!c.lastResult) continue;
|
|
51
|
+
console.log(`${c.lastResult === "pass" ? "✅" : "❌"} ${c.id} ${c.query.slice(0, 60)}${c.lastResult === "fail" ? " (expected: " + c.expectSlugs.join(", ") + ")" : ""}`);
|
|
52
|
+
}
|
|
53
|
+
const rate = res.ran > 0 ? Math.round((res.passed / res.ran) * 100) : 0;
|
|
54
|
+
console.log(`\n${res.passed}/${res.ran} passing (${rate}%)${res.orphaned ? `, ${res.orphaned} orphaned case(s) dropped` : ""}${res.total > res.ran ? `, ${res.total - res.ran} deferred to next rotation` : ""}.`);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (cmd === "add") {
|
|
59
|
+
const rest = args.slice(1);
|
|
60
|
+
const expect = [];
|
|
61
|
+
let source = "manual", note = "";
|
|
62
|
+
for (let i = 0; i < rest.length; i++) {
|
|
63
|
+
if (rest[i] === "--expect") { expect.push(rest[++i]); continue; }
|
|
64
|
+
if (rest[i] === "--source") { source = rest[++i]; continue; }
|
|
65
|
+
if (rest[i] === "--note") { note = rest[++i] || ""; continue; }
|
|
66
|
+
}
|
|
67
|
+
const query = rest.filter((a, i) => {
|
|
68
|
+
if (a === "--expect" || a === "--source" || a === "--note") return false;
|
|
69
|
+
if (i > 0 && ["--expect", "--source", "--note"].includes(rest[i - 1])) return false;
|
|
70
|
+
return true;
|
|
71
|
+
}).join(" ").trim();
|
|
72
|
+
if (!query || expect.length === 0) return usage();
|
|
73
|
+
// Warn (don't refuse) on an unknown slug — the pack may be created next.
|
|
74
|
+
const packs = require("../core/packs");
|
|
75
|
+
const entities = require("../core/entities");
|
|
76
|
+
for (const s of expect) {
|
|
77
|
+
let known = false;
|
|
78
|
+
try { known = !!packs.readPack(s); } catch (e) {}
|
|
79
|
+
if (!known) { try { known = !!entities.readEntity(s); } catch (e) {} }
|
|
80
|
+
if (!known) console.log(`⚠️ ${s} is not a known pack dir or entity slug right now — the case will grade as orphaned until it exists.`);
|
|
81
|
+
}
|
|
82
|
+
const entry = regressions.addCase({ query, expectSlugs: expect, source, note });
|
|
83
|
+
console.log(`🧪 Case ${entry.id} added — nightly recall must surface ${expect.join(", ")} for: "${query}"`);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
usage();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
module.exports = { run };
|
package/bin/remind.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// CLI: prospective memory — "next time X comes up, mention Y".
|
|
2
|
+
// open-claudia remind "<note>" --pack <dir> | --entity <slug> [--sticky] [--expires <YYYY-MM-DD>]
|
|
3
|
+
// open-claudia remind list
|
|
4
|
+
// open-claudia remind remove <id>
|
|
5
|
+
// Reminders are topic-bound, not time-bound: they fire when the target pack/
|
|
6
|
+
// entity next surfaces in recall injection or is opened with pack show /
|
|
7
|
+
// entity show. Time-based reminding belongs to schedule-wakeup / cron-add;
|
|
8
|
+
// condition-based reminding belongs to watch-add.
|
|
9
|
+
|
|
10
|
+
const prospective = require("../core/prospective");
|
|
11
|
+
|
|
12
|
+
function usage() {
|
|
13
|
+
console.error('Usage: open-claudia remind "<note>" --pack <dir> | --entity <slug> [--sticky] [--expires <YYYY-MM-DD>]');
|
|
14
|
+
console.error(" open-claudia remind list | remove <id>");
|
|
15
|
+
process.exitCode = 1;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function run(args) {
|
|
19
|
+
const cmd = (args[0] || "").toLowerCase();
|
|
20
|
+
|
|
21
|
+
if (cmd === "" || cmd === "help" || cmd === "--help") return usage();
|
|
22
|
+
|
|
23
|
+
if (cmd === "list") {
|
|
24
|
+
const all = prospective.listReminders();
|
|
25
|
+
if (all.length === 0) return console.log("No pending reminders.");
|
|
26
|
+
for (const r of all) {
|
|
27
|
+
const extras = [r.sticky ? "sticky" : "one-shot", r.expiresAt ? `expires ${String(r.expiresAt).slice(0, 10)}` : ""].filter(Boolean).join(", ");
|
|
28
|
+
console.log(`${r.id} [${r.targetType}:${r.targetSlug}] (${extras}, asked ${String(r.createdAt).slice(0, 10)})\n ${r.note}`);
|
|
29
|
+
}
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (cmd === "remove") {
|
|
34
|
+
const id = args[1];
|
|
35
|
+
if (!id) return usage();
|
|
36
|
+
if (!prospective.removeReminder(id)) { console.error(`No reminder: ${id} (open-claudia remind list)`); process.exitCode = 1; return; }
|
|
37
|
+
console.log(`Removed reminder ${id}.`);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Default verb: add. The note is everything that isn't a flag.
|
|
42
|
+
const rest = args.slice();
|
|
43
|
+
const takeFlag = (flag, hasValue = true) => {
|
|
44
|
+
const i = rest.indexOf(flag);
|
|
45
|
+
if (i === -1) return null;
|
|
46
|
+
const v = hasValue ? rest[i + 1] : true;
|
|
47
|
+
rest.splice(i, hasValue ? 2 : 1);
|
|
48
|
+
return v == null ? "" : v;
|
|
49
|
+
};
|
|
50
|
+
const packArg = takeFlag("--pack");
|
|
51
|
+
const entityArg = takeFlag("--entity");
|
|
52
|
+
const sticky = takeFlag("--sticky", false) === true;
|
|
53
|
+
const expires = takeFlag("--expires");
|
|
54
|
+
const note = rest.join(" ").trim();
|
|
55
|
+
|
|
56
|
+
if (!note || (!packArg && !entityArg) || (packArg && entityArg)) return usage();
|
|
57
|
+
|
|
58
|
+
// Resolve the target now so a typo surfaces immediately, not as a reminder
|
|
59
|
+
// that silently never fires (dream GC would drop it as an orphan).
|
|
60
|
+
let targetType, targetSlug, targetName;
|
|
61
|
+
if (packArg) {
|
|
62
|
+
const p = require("../core/packs").findPack(packArg);
|
|
63
|
+
if (!p) { console.error(`No pack: ${packArg} (open-claudia pack list)`); process.exitCode = 1; return; }
|
|
64
|
+
targetType = "pack"; targetSlug = p.dir; targetName = p.name || p.dir;
|
|
65
|
+
} else {
|
|
66
|
+
const e = require("../core/entities").findEntity(entityArg);
|
|
67
|
+
if (!e) { console.error(`No entity: ${entityArg} (open-claudia entity list)`); process.exitCode = 1; return; }
|
|
68
|
+
targetType = "entity"; targetSlug = e.slug; targetName = e.name || e.slug;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
let expiresAt = null;
|
|
72
|
+
if (expires) {
|
|
73
|
+
const t = Date.parse(expires);
|
|
74
|
+
if (!Number.isFinite(t)) { console.error(`Bad --expires date: ${expires} (use ISO, e.g. 2026-09-01)`); process.exitCode = 1; return; }
|
|
75
|
+
expiresAt = new Date(t).toISOString();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const entry = prospective.addReminder({ note, targetType, targetSlug, sticky, expiresAt });
|
|
79
|
+
console.log(`📌 Reminder ${entry.id} armed — fires when ${targetType} "${targetName}" next comes up${sticky ? " (sticky: repeats until removed)" : " (one-shot)"}${expiresAt ? `, expires ${expiresAt.slice(0, 10)}` : ""}.`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
module.exports = { run };
|
package/bin/tool.js
CHANGED
|
@@ -214,9 +214,14 @@ async function run(args) {
|
|
|
214
214
|
console.log(`Runs: ${t.runCount}${t.lastUsed ? ` · last used ${t.lastUsed.slice(0, 16).replace("T", " ")}` : ""}${exit}`);
|
|
215
215
|
}
|
|
216
216
|
if (t.verbs && Object.keys(t.verbs).length) {
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
217
|
+
let certs = {};
|
|
218
|
+
try { certs = tools.certificationForState({ verbs: t.verbs, history: t.history }); } catch (e) {}
|
|
219
|
+
const rows = Object.entries(t.verbs).map(([v, s]) => {
|
|
220
|
+
const c = certs[v];
|
|
221
|
+
const cert = c ? ` · ${c.tier}${c.tier === "certified" ? "" : ` (${c.cleanRuns}/${tools.CERTIFY_CLEAN_RUNS} clean)`}` : "";
|
|
222
|
+
return ` • ${v}: ${s.runs} run(s), last exit ${s.lastExit}${s.lastSuccess ? `, last success ${s.lastSuccess.slice(0, 10)}` : ""}${cert}`;
|
|
223
|
+
});
|
|
224
|
+
console.log("Verbs (certification is earned trust, informational only):\n" + rows.join("\n"));
|
|
220
225
|
}
|
|
221
226
|
try {
|
|
222
227
|
const log = repo.toolLog(t.name, 5);
|
|
@@ -23,6 +23,28 @@ const CONTEXT_MAX_MESSAGES = 40;
|
|
|
23
23
|
const CONTEXT_BODY_LIMIT = 280;
|
|
24
24
|
const COLD_START_WINDOW = 15;
|
|
25
25
|
|
|
26
|
+
// Kazee Media docs from mobile uploads often carry no fileName, so the saved
|
|
27
|
+
// extension must come from the mimeType (or the blob URL) — otherwise every
|
|
28
|
+
// video/document lands on disk as ".bin".
|
|
29
|
+
const MIME_EXT = {
|
|
30
|
+
"video/mp4": ".mp4",
|
|
31
|
+
"video/quicktime": ".mov",
|
|
32
|
+
"video/webm": ".webm",
|
|
33
|
+
"video/x-matroska": ".mkv",
|
|
34
|
+
"audio/mpeg": ".mp3",
|
|
35
|
+
"audio/mp4": ".m4a",
|
|
36
|
+
"audio/x-m4a": ".m4a",
|
|
37
|
+
"audio/aac": ".aac",
|
|
38
|
+
"audio/ogg": ".ogg",
|
|
39
|
+
"audio/wav": ".wav",
|
|
40
|
+
"image/jpeg": ".jpg",
|
|
41
|
+
"image/png": ".png",
|
|
42
|
+
"image/gif": ".gif",
|
|
43
|
+
"image/webp": ".webp",
|
|
44
|
+
"application/pdf": ".pdf",
|
|
45
|
+
"application/zip": ".zip",
|
|
46
|
+
};
|
|
47
|
+
|
|
26
48
|
class KazeeAdapter {
|
|
27
49
|
constructor({ id = "kazee", url, token, ownerUserId, botUserId }) {
|
|
28
50
|
this.id = id;
|
|
@@ -600,10 +622,22 @@ class KazeeAdapter {
|
|
|
600
622
|
async downloadMedia(media) {
|
|
601
623
|
if (!media || !media.fileId) return null;
|
|
602
624
|
const url = media.fileId; // Kazee passes the cloud URL through fileId.
|
|
603
|
-
|
|
625
|
+
let urlExt = "";
|
|
626
|
+
try { urlExt = path.extname(new URL(url).pathname); } catch (e) {}
|
|
627
|
+
const mime = (media.mimeType || "").toLowerCase().split(";")[0].trim();
|
|
628
|
+
const ext = path.extname(media.fileName || "")
|
|
629
|
+
|| urlExt
|
|
630
|
+
|| MIME_EXT[mime]
|
|
631
|
+
|| (media.type === "voice" ? ".ogg"
|
|
632
|
+
: media.type === "photo" ? ".jpg"
|
|
633
|
+
: media.type === "video" ? ".mp4"
|
|
634
|
+
: media.type === "audio" ? ".m4a"
|
|
635
|
+
: ".bin");
|
|
604
636
|
const baseDir = media.type === "document" ? FILES_DIR : TEMP_DIR;
|
|
605
|
-
const
|
|
606
|
-
|
|
637
|
+
const keepName = media.type === "document" && media.fileName;
|
|
638
|
+
const nameSuffix = keepName && !path.extname(media.fileName) && ext !== ".bin" ? ext : "";
|
|
639
|
+
const localPath = keepName
|
|
640
|
+
? path.join(baseDir, media.fileName + nameSuffix)
|
|
607
641
|
: path.join(baseDir, `kz-${Date.now()}${ext}`);
|
|
608
642
|
await this._downloadUrl(url, localPath);
|
|
609
643
|
return localPath;
|
|
@@ -8,6 +8,16 @@ const DEFAULT_ICE_SERVERS = [
|
|
|
8
8
|
{ urls: "stun:coturn.kazee.africa:3478" },
|
|
9
9
|
{ urls: "stun:stun.l.google.com:19302" },
|
|
10
10
|
];
|
|
11
|
+
// FCM push TTL is 60s and chat-central treats ringing logs as fresh for 90s.
|
|
12
|
+
// Must comfortably outlast the mobile client's own 30s ring timeout: a callee
|
|
13
|
+
// device auto-ends at 30s, the human then answers late, and chat-central
|
|
14
|
+
// revives the CallLog — the room has to still exist when that answer lands
|
|
15
|
+
// (first live outbound test 2026-08-08: end at 30s, real answer at 44s).
|
|
16
|
+
const RING_TIMEOUT_MS = 60_000;
|
|
17
|
+
const OUTGOING_GREETING = "Hey, it's Claudia. Can you hear me okay?";
|
|
18
|
+
// Inbound must greet too: callers get silence until they speak first, which
|
|
19
|
+
// reads as a dead call (live test 2026-08-08 — media was up, caller hung up).
|
|
20
|
+
const INCOMING_GREETING = "Hey, it's Claudia — you're through. Can you hear me?";
|
|
11
21
|
|
|
12
22
|
class KazeeCallManager {
|
|
13
23
|
constructor({ adapter, SessionImpl = KazeeCallSession, janusUrl = process.env.KAZEE_JANUS_URL || DEFAULT_JANUS_URL,
|
|
@@ -17,12 +27,45 @@ class KazeeCallManager {
|
|
|
17
27
|
this.janusUrl = janusUrl;
|
|
18
28
|
this.iceServers = iceServers;
|
|
19
29
|
this.session = null;
|
|
30
|
+
this._ringTimer = null;
|
|
20
31
|
}
|
|
21
32
|
|
|
22
33
|
wire(socket) {
|
|
23
34
|
socket.on("call:incoming", (payload) => this.incoming(payload));
|
|
24
35
|
socket.on("call:ended", (payload) => this.ended(payload));
|
|
25
|
-
socket.on("call:rejected", (payload) => this.
|
|
36
|
+
socket.on("call:rejected", (payload) => this.rejected(payload));
|
|
37
|
+
socket.on("call:accepted", (payload) => this.accepted(payload));
|
|
38
|
+
socket.on("call:ringing", (payload) => {
|
|
39
|
+
if (this.session) console.log(`Kazee call ringing: chat=${String(payload?.chatId || "")}`);
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Janus signalling always worked; the media leg never did — the hardcoded
|
|
44
|
+
// default is STUN-only, and behind NAT only a TURN relay yields a workable
|
|
45
|
+
// candidate pair (zero RTP ever arrived, 2026-07-30). chat-central mints
|
|
46
|
+
// TURN credentials at /webrtc/config — the same endpoint the web and mobile
|
|
47
|
+
// clients use. werift's ICE stack is UDP-only, so tcp/tls variants are
|
|
48
|
+
// filtered out before they can break candidate gathering.
|
|
49
|
+
async _fetchIceServers() {
|
|
50
|
+
if (typeof this.adapter?._request !== "function") return this.iceServers;
|
|
51
|
+
try {
|
|
52
|
+
const res = await this.adapter._request("GET", "/webrtc/config");
|
|
53
|
+
const fetched = Array.isArray(res?.config?.iceServers) ? res.config.iceServers : [];
|
|
54
|
+
const usable = fetched.filter((server) => {
|
|
55
|
+
const url = String(Array.isArray(server.urls) ? server.urls[0] : server.urls || "");
|
|
56
|
+
if (url.startsWith("stun:")) return true;
|
|
57
|
+
return url.startsWith("turn:") && !url.includes("transport=tcp");
|
|
58
|
+
});
|
|
59
|
+
const turns = usable.filter((s) => String(Array.isArray(s.urls) ? s.urls[0] : s.urls).startsWith("turn:")).length;
|
|
60
|
+
if (turns > 0) {
|
|
61
|
+
console.log(`Kazee call ICE config: ${usable.length} servers (${turns} TURN) from /webrtc/config`);
|
|
62
|
+
return usable;
|
|
63
|
+
}
|
|
64
|
+
console.warn("Kazee call ICE config: /webrtc/config returned no usable TURN entry, using defaults");
|
|
65
|
+
} catch (error) {
|
|
66
|
+
console.error(`Kazee call ICE config fetch failed, using defaults: ${error.message}`);
|
|
67
|
+
}
|
|
68
|
+
return this.iceServers;
|
|
26
69
|
}
|
|
27
70
|
|
|
28
71
|
async incoming(payload = {}) {
|
|
@@ -44,9 +87,10 @@ class KazeeCallManager {
|
|
|
44
87
|
if (callType !== "audio") return reject("unsupported_media");
|
|
45
88
|
if (this.session) return reject("busy");
|
|
46
89
|
|
|
90
|
+
const iceServers = await this._fetchIceServers();
|
|
47
91
|
const session = new this.SessionImpl({
|
|
48
92
|
roomId, chatId, callerId, botUserId: this.adapter.botUserId,
|
|
49
|
-
janusUrl: this.janusUrl, iceServers:
|
|
93
|
+
janusUrl: this.janusUrl, iceServers, greeting: INCOMING_GREETING,
|
|
50
94
|
onTranscript: (text, meta) => this._transcript(text, payload, meta),
|
|
51
95
|
onEnded: () => { if (this.session === session) this.session = null; },
|
|
52
96
|
});
|
|
@@ -65,6 +109,70 @@ class KazeeCallManager {
|
|
|
65
109
|
}
|
|
66
110
|
}
|
|
67
111
|
|
|
112
|
+
// Bot-initiated call. Mirrors what a human caller's client does: create the
|
|
113
|
+
// Janus room, publish into it, then emit call:invite so chat-central rings
|
|
114
|
+
// the callee (socket + FCM/VoIP push). The callee's publisher appearing in
|
|
115
|
+
// the room is what completes the media leg — the existing auto-subscribe in
|
|
116
|
+
// the session handles that, so acceptance needs no extra media work here.
|
|
117
|
+
async outgoing({ chatId, toUserId } = {}) {
|
|
118
|
+
const target = String(toUserId || this.adapter.ownerUserId || "");
|
|
119
|
+
const chat = String(chatId || "");
|
|
120
|
+
if (!target || !chat) throw new Error("missing chatId/toUserId");
|
|
121
|
+
if (this.session) throw new Error("busy: a call session is already active");
|
|
122
|
+
if (!this.adapter._socket?.connected) throw new Error("kazee socket is not connected");
|
|
123
|
+
const roomId = Math.floor(Math.random() * 1e9);
|
|
124
|
+
const iceServers = await this._fetchIceServers();
|
|
125
|
+
// _transcript() reads chatId/fromUserId/roomId off the inbound invite
|
|
126
|
+
// payload; for outbound the remote human is the "from" side.
|
|
127
|
+
const remote = { chatId: chat, fromUserId: target, roomId, fromUserName: "" };
|
|
128
|
+
const session = new this.SessionImpl({
|
|
129
|
+
roomId, chatId: chat, callerId: target, botUserId: this.adapter.botUserId,
|
|
130
|
+
janusUrl: this.janusUrl, iceServers, asCaller: true, greeting: OUTGOING_GREETING,
|
|
131
|
+
onTranscript: (text, meta) => this._transcript(text, remote, meta),
|
|
132
|
+
onEnded: () => {
|
|
133
|
+
this._clearRingTimer();
|
|
134
|
+
if (this.session === session) this.session = null;
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
this.session = session;
|
|
138
|
+
try {
|
|
139
|
+
await session.start();
|
|
140
|
+
} catch (error) {
|
|
141
|
+
this.session = null;
|
|
142
|
+
await session.close("connect-failed").catch(() => {});
|
|
143
|
+
throw error;
|
|
144
|
+
}
|
|
145
|
+
console.log(`Kazee call outgoing: room=${roomId} chat=${chat} to=${target}`);
|
|
146
|
+
this.adapter._socket.emit("call:invite", { toUserId: target, callType: "audio", chatId: chat, roomId });
|
|
147
|
+
this._ringTimer = setTimeout(() => {
|
|
148
|
+
this._ringTimer = null;
|
|
149
|
+
this._ringTimeout(target, chat, roomId).catch((error) => console.error("Kazee call ring timeout:", error.message));
|
|
150
|
+
}, RING_TIMEOUT_MS);
|
|
151
|
+
if (typeof this._ringTimer.unref === "function") this._ringTimer.unref();
|
|
152
|
+
return { roomId, chatId: chat, toUserId: target };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
accepted(payload = {}) {
|
|
156
|
+
if (!this.session || !this._ringTimer) return;
|
|
157
|
+
console.log(`Kazee call answered: room=${this.session.roomId} chat=${String(payload.chatId || "")}`);
|
|
158
|
+
this._clearRingTimer();
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async _ringTimeout(toUserId, chatId, roomId) {
|
|
162
|
+
const session = this.session;
|
|
163
|
+
if (!session || session.roomId !== roomId) return;
|
|
164
|
+
console.log(`Kazee call unanswered: room=${roomId} to=${toUserId}`);
|
|
165
|
+
// Caller hanging up while ringing: chat-central finalizes the CallLog as
|
|
166
|
+
// missed and dismisses the callee's ringing UI/push.
|
|
167
|
+
this.adapter._socket?.emit("call:end", { toUserId, chatId });
|
|
168
|
+
this.session = null;
|
|
169
|
+
await session.close("no-answer").catch(() => {});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
_clearRingTimer() {
|
|
173
|
+
if (this._ringTimer) { clearTimeout(this._ringTimer); this._ringTimer = null; }
|
|
174
|
+
}
|
|
175
|
+
|
|
68
176
|
_transcript(text, incoming, meta = {}) {
|
|
69
177
|
const session = this.session;
|
|
70
178
|
if (!session || !text) return;
|
|
@@ -106,11 +214,29 @@ class KazeeCallManager {
|
|
|
106
214
|
async ended(payload = {}) {
|
|
107
215
|
if (!this.session) return;
|
|
108
216
|
if (payload.roomId != null && Number(payload.roomId) !== this.session.roomId) return;
|
|
217
|
+
// While our outbound ring is live, a call:ended is usually a callee DEVICE
|
|
218
|
+
// giving up (mobile auto-ends its ring at 30s), not the human declining.
|
|
219
|
+
// chat-central revives the CallLog if they answer late, so the caller must
|
|
220
|
+
// keep the room alive until its own ring timeout — tearing down here left
|
|
221
|
+
// the late answer joining a destroyed room, stuck on "Connecting" (2026-08-08).
|
|
222
|
+
// A deliberate decline arrives as call:rejected and still closes.
|
|
223
|
+
if (this._ringTimer) {
|
|
224
|
+
console.log(`Kazee call ignoring ended-while-ringing: room=${this.session.roomId}`);
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
109
227
|
const session = this.session;
|
|
110
228
|
this.session = null;
|
|
111
229
|
await session.close("remote-ended");
|
|
112
230
|
}
|
|
113
231
|
|
|
232
|
+
async rejected(payload = {}) {
|
|
233
|
+
if (!this.session) return;
|
|
234
|
+
if (payload.roomId != null && Number(payload.roomId) !== this.session.roomId) return;
|
|
235
|
+
const session = this.session;
|
|
236
|
+
this.session = null;
|
|
237
|
+
await session.close("remote-rejected");
|
|
238
|
+
}
|
|
239
|
+
|
|
114
240
|
async stop() {
|
|
115
241
|
if (!this.session) return;
|
|
116
242
|
const session = this.session;
|