@inetafrica/open-claudia 3.5.33 → 3.5.58

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.
Files changed (41) hide show
  1. package/CHANGELOG.md +65 -5
  2. package/bin/cli.js +10 -3
  3. package/bot.js +11 -0
  4. package/channels/kazee/adapter.js +220 -23
  5. package/channels/kazee/call/camera.js +233 -0
  6. package/channels/kazee/call/job-steps.js +81 -0
  7. package/channels/kazee/call/manager.js +1056 -27
  8. package/channels/kazee/call/miss-capture.js +82 -0
  9. package/channels/kazee/call/quick-voice.js +610 -0
  10. package/channels/kazee/call/session.js +787 -36
  11. package/channels/kazee/call/thread-context.js +100 -0
  12. package/channels/kazee/catch-up.js +275 -0
  13. package/core/actions.js +13 -4
  14. package/core/enforcer.js +1 -1
  15. package/core/handlers.js +14 -16
  16. package/core/identity.js +15 -1
  17. package/core/loopback.js +4 -1
  18. package/core/media.js +39 -1
  19. package/core/run-context.js +27 -3
  20. package/core/runner.js +70 -1
  21. package/core/state.js +6 -2
  22. package/core/switchboard/brain-link.js +161 -0
  23. package/core/switchboard/call-host.js +298 -0
  24. package/core/switchboard/handoff.js +80 -0
  25. package/core/switchboard/ipc.js +130 -0
  26. package/core/switchboard/supervisor.js +225 -0
  27. package/core/system-prompt.js +44 -4
  28. package/core/transcripts.js +39 -0
  29. package/core/tts-voice.js +62 -4
  30. package/core/turn-journal.js +23 -1
  31. package/package.json +2 -2
  32. package/test-call-speech.js +7 -0
  33. package/test-enforcer.js +13 -0
  34. package/test-fixtures/switchboard-fake-brain.js +20 -0
  35. package/test-kazee-call-manager.js +22 -4
  36. package/test-kazee-call-session.js +345 -8
  37. package/test-provider-compaction.js +8 -0
  38. package/test-provider-language.js +17 -5
  39. package/test-provider-prompt-parity.js +1 -0
  40. package/test-provider-runner.js +2 -0
  41. package/test-run-lock.js +24 -0
@@ -0,0 +1,233 @@
1
+ "use strict";
2
+
3
+ // Stills of the caller's camera, so the full agent can look when they ask
4
+ // ("Can you see me?", "What am I holding?"). Their VP8 video arrives as RTP;
5
+ // whole frames go to a local ffmpeg as an IVF stream, and ffmpeg keeps one
6
+ // JPEG up to date every couple of seconds. Only the latest still is kept, in
7
+ // a temp folder removed when the call ends. 11 Oct: the call took only audio,
8
+ // so a camera switched on mid-call was never seen.
9
+
10
+ const fs = require("fs");
11
+ const os = require("os");
12
+ const path = require("path");
13
+ const { spawn } = require("child_process");
14
+ const { dePacketizeRtpPackets } = require("werift");
15
+
16
+ const STILL_EVERY_S = 2;
17
+ const STILL_SIZE = 768;
18
+ // Ask the sender for a keyframe at most this often while there is no
19
+ // picture yet, or after a lost packet left the decoder without one.
20
+ const KEYFRAME_RETRY_MS = 2000;
21
+ // Frames still waiting for packets are dropped after this many newer ones.
22
+ const MAX_OPEN_FRAMES = 8;
23
+ // RFC 7742 / 3GPP TS 26.114 coordination of video orientation.
24
+ const VIDEO_ORIENTATION_URI = "urn:3gpp:video-orientation";
25
+ const clog = (msg) => console.log(`${new Date().toISOString()} Kazee call camera: ${msg}`);
26
+
27
+ // The 2 low bits of the orientation byte: clockwise turns the receiver must
28
+ // apply (0, 90, 180, 270 degrees).
29
+ function orientationTurns(byte) { return Number(byte) & 0x03; }
30
+
31
+ // The rotation filter for a still, from the sender's orientation turns.
32
+ function rotationFilter(turns) {
33
+ return ["", "transpose=1", "hflip,vflip", "transpose=2"][turns & 3];
34
+ }
35
+
36
+ // A VP8 keyframe's width and height (RFC 6386 9.1), or null.
37
+ function vp8KeyframeSize(frame) {
38
+ if (!frame || frame.length < 10 || (frame[0] & 0x01) !== 0) return null;
39
+ if (frame[3] !== 0x9d || frame[4] !== 0x01 || frame[5] !== 0x2a) return null;
40
+ return { width: frame.readUInt16LE(6) & 0x3fff, height: frame.readUInt16LE(8) & 0x3fff };
41
+ }
42
+
43
+ function ivfHeader({ width, height }) {
44
+ const header = Buffer.alloc(32);
45
+ header.write("DKIF", 0, "ascii");
46
+ header.writeUInt16LE(0, 4);
47
+ header.writeUInt16LE(32, 6);
48
+ header.write("VP80", 8, "ascii");
49
+ header.writeUInt16LE(width, 12);
50
+ header.writeUInt16LE(height, 14);
51
+ header.writeUInt32LE(90000, 16); // time base 1/90000: RTP video clock
52
+ header.writeUInt32LE(1, 20);
53
+ return header;
54
+ }
55
+
56
+ function ivfFrame(data, pts) {
57
+ const header = Buffer.alloc(12);
58
+ header.writeUInt32LE(data.length, 0);
59
+ header.writeBigUInt64LE(BigInt(Math.max(0, pts)), 4);
60
+ return Buffer.concat([header, data]);
61
+ }
62
+
63
+ // The extmap id the offer gives video orientation, or null.
64
+ function orientationExtensionId(sdp) {
65
+ const match = String(sdp || "").match(/a=extmap:(\d+)(?:\/\w+)? urn:3gpp:video-orientation/);
66
+ return match ? Number(match[1]) : null;
67
+ }
68
+
69
+ class CameraStills {
70
+ constructor({ feedId, requestKeyframe = async () => {}, spawnImpl = spawn, dir = null, now = Date.now, log = clog } = {}) {
71
+ this.feedId = String(feedId);
72
+ this.requestKeyframe = requestKeyframe;
73
+ this.spawnImpl = spawnImpl;
74
+ this.dir = dir || fs.mkdtempSync(path.join(os.tmpdir(), "oc-call-camera-"));
75
+ this.file = path.join(this.dir, "latest.jpg");
76
+ this.now = now;
77
+ this.log = log;
78
+ this.orientationId = null;
79
+ this.turns = 0;
80
+ this._frames = new Map(); // RTP timestamp -> packets
81
+ this._lastSeq = null;
82
+ this._needKeyframe = true;
83
+ this._keyframeAskedAt = 0;
84
+ this._ffmpeg = null;
85
+ this._firstTimestamp = null;
86
+ this._size = null;
87
+ this._frameCount = 0;
88
+ this._stillAt = 0;
89
+ this._lastPacketAt = 0;
90
+ this.closed = false;
91
+ this._watch = null;
92
+ }
93
+
94
+ setOrientationExtension(id) { this.orientationId = Number.isInteger(id) ? id : null; }
95
+
96
+ // One RTP packet of the caller's video (werift RtpPacket).
97
+ push(packet) {
98
+ if (this.closed || !packet?.header) return;
99
+ this._lastPacketAt = this.now();
100
+ const { header } = packet;
101
+ if (this.orientationId != null) {
102
+ const ext = (header.extensions || []).find((item) => item.id === this.orientationId);
103
+ if (ext?.payload?.length) this._turn(orientationTurns(ext.payload[0]));
104
+ }
105
+ // A gap in sequence numbers: some frame is incomplete, and decoding goes
106
+ // wrong until the next keyframe.
107
+ if (this._lastSeq != null) {
108
+ const step = (header.sequenceNumber - this._lastSeq + 65536) % 65536;
109
+ if (step > 1 && step < 32768) this._lost();
110
+ }
111
+ if (this._lastSeq == null || ((header.sequenceNumber - this._lastSeq + 65536) % 65536) < 32768) this._lastSeq = header.sequenceNumber;
112
+ const list = this._frames.get(header.timestamp) || [];
113
+ list.push(packet);
114
+ this._frames.set(header.timestamp, list);
115
+ if (header.marker) this._complete(header.timestamp);
116
+ while (this._frames.size > MAX_OPEN_FRAMES) this._frames.delete(this._frames.keys().next().value);
117
+ this._maybeAskKeyframe();
118
+ }
119
+
120
+ // The latest still: its path and age, or null before the first one.
121
+ latest() {
122
+ if (!this._stillAt) return null;
123
+ return { path: this.file, ageMs: Math.max(0, this.now() - this._stillAt), receiving: this.now() - this._lastPacketAt < 3000 };
124
+ }
125
+
126
+ _lost() {
127
+ if (this._needKeyframe) return;
128
+ this._needKeyframe = true;
129
+ this._keyframeAskedAt = 0;
130
+ }
131
+
132
+ _maybeAskKeyframe() {
133
+ if (!this._needKeyframe || this.now() - this._keyframeAskedAt < KEYFRAME_RETRY_MS) return;
134
+ this._keyframeAskedAt = this.now();
135
+ Promise.resolve(this.requestKeyframe()).catch(() => {});
136
+ }
137
+
138
+ _turn(turns) {
139
+ if (turns === this.turns) return;
140
+ this.log(`orientation ${this.turns * 90} -> ${turns * 90} deg feed=${this.feedId}`);
141
+ this.turns = turns;
142
+ // The rotation is a filter setting: start ffmpeg again with the new one.
143
+ this._stopFfmpeg();
144
+ this._needKeyframe = true;
145
+ this._keyframeAskedAt = 0;
146
+ }
147
+
148
+ _complete(timestamp) {
149
+ const packets = (this._frames.get(timestamp) || []).sort((a, b) => ((a.header.sequenceNumber - b.header.sequenceNumber + 32768 + 65536) % 65536) - 32768);
150
+ this._frames.delete(timestamp);
151
+ for (let index = 1; index < packets.length; index += 1) {
152
+ if ((packets[index].header.sequenceNumber - packets[index - 1].header.sequenceNumber + 65536) % 65536 !== 1) return this._lost();
153
+ }
154
+ let frame;
155
+ try { frame = dePacketizeRtpPackets("VP8", packets); } catch (_) { return this._lost(); }
156
+ if (!frame?.data?.length) return;
157
+ const size = frame.isKeyframe ? vp8KeyframeSize(frame.data) : null;
158
+ if (this._needKeyframe) {
159
+ if (!size) return;
160
+ this._needKeyframe = false;
161
+ }
162
+ if (!this._ffmpeg) {
163
+ if (!size) { this._needKeyframe = true; return; }
164
+ this._startFfmpeg(size, timestamp);
165
+ }
166
+ const pts = ((timestamp - this._firstTimestamp) >>> 0);
167
+ try { this._ffmpeg.stdin.write(ivfFrame(frame.data, pts)); } catch (_) {}
168
+ }
169
+
170
+ _startFfmpeg(size, timestamp) {
171
+ this._size = size;
172
+ this._firstTimestamp = timestamp;
173
+ const rotate = rotationFilter(this.turns);
174
+ // select, not fps: fps holds each still back until later frames decide
175
+ // it, so the newest picture would always be one interval late.
176
+ const filter = [
177
+ `select='isnan(prev_selected_t)+gte(t-prev_selected_t\\,${STILL_EVERY_S})'`,
178
+ rotate,
179
+ `scale=${STILL_SIZE}:${STILL_SIZE}:force_original_aspect_ratio=decrease`,
180
+ ].filter(Boolean).join(",");
181
+ // No probing: by default ffmpeg reads seconds of input before its first
182
+ // picture, and a camera stream never ends. (-fflags nobuffer broke VP8
183
+ // decoding: "Invalid data found when processing input".)
184
+ const args = ["-hide_banner", "-loglevel", "error", "-probesize", "32", "-analyzeduration", "0", "-f", "ivf", "-i", "pipe:0",
185
+ "-vf", filter, "-fps_mode", "passthrough", "-update", "1", "-atomic_writing", "1", "-q:v", "4", "-y", this.file];
186
+ const proc = this.spawnImpl("ffmpeg", args, { stdio: ["pipe", "ignore", "pipe"] });
187
+ this._ffmpeg = proc;
188
+ proc.stdin.on?.("error", () => {});
189
+ proc.stdin.write(ivfHeader(size));
190
+ proc.stderr?.on?.("data", (chunk) => this.log(`ffmpeg: ${String(chunk).trim().slice(0, 200)}`));
191
+ proc.on?.("close", (code) => {
192
+ if (this._ffmpeg === proc) { this._ffmpeg = null; this._needKeyframe = true; }
193
+ if (!this.closed && code) this.log(`ffmpeg exited ${code} feed=${this.feedId}`);
194
+ });
195
+ proc.on?.("error", (error) => this.log(`ffmpeg failed: ${error.message}`));
196
+ this.log(`stills on: ${size.width}x${size.height} turn=${this.turns * 90} feed=${this.feedId}`);
197
+ this._watchStill();
198
+ }
199
+
200
+ // ffmpeg renames each finished still into place: note when one lands.
201
+ _watchStill() {
202
+ if (this._watch) return;
203
+ this._watch = setInterval(() => {
204
+ try {
205
+ const at = fs.statSync(this.file).mtimeMs;
206
+ if (at > this._stillAt) {
207
+ if (!this._stillAt) this.log(`first still feed=${this.feedId}`);
208
+ this._stillAt = at;
209
+ }
210
+ } catch (_) {}
211
+ }, 500);
212
+ this._watch.unref?.();
213
+ }
214
+
215
+ _stopFfmpeg() {
216
+ const proc = this._ffmpeg;
217
+ this._ffmpeg = null;
218
+ if (!proc) return;
219
+ try { proc.stdin.end(); } catch (_) {}
220
+ setTimeout(() => { try { proc.kill("SIGKILL"); } catch (_) {} }, 1000).unref?.();
221
+ }
222
+
223
+ close() {
224
+ if (this.closed) return;
225
+ this.closed = true;
226
+ this._stopFfmpeg();
227
+ if (this._watch) clearInterval(this._watch);
228
+ this._frames.clear();
229
+ setTimeout(() => { try { fs.rmSync(this.dir, { recursive: true, force: true }); } catch (_) {} }, 1500).unref?.();
230
+ }
231
+ }
232
+
233
+ module.exports = { CameraStills, VIDEO_ORIENTATION_URI, orientationExtensionId, orientationTurns, rotationFilter, vp8KeyframeSize, ivfHeader, ivfFrame };
@@ -0,0 +1,81 @@
1
+ "use strict";
2
+
3
+ // What the full agent is doing on a call, in plain words, from its tool
4
+ // starts: "reading files", "checking GitLab". Fixed rules, no model; ids,
5
+ // paths and commands are never kept. Feeds the call's job board, so the quick
6
+ // voice can answer "what's the status?" from real work. Ported from the 4 Oct
7
+ // calls build (never-silent.js stepForTool).
8
+
9
+ const STEPS = Object.freeze({
10
+ notes: "checking my notes",
11
+ past: "searching our past conversations",
12
+ tool: "looking for the right tool",
13
+ servers: "checking the servers",
14
+ files: "reading files",
15
+ web: "looking it up online",
16
+ change: "making the change",
17
+ check: "running a check",
18
+ tasks: "checking the tasks",
19
+ code: "checking the code history",
20
+ helper: "getting a helper to dig through it",
21
+ calendar: "checking the calendar",
22
+ service: "checking a service",
23
+ });
24
+
25
+ // `open-claudia tool run <id>`: a plain name only for these; any other tool
26
+ // is "running a check" (its id is never said or kept).
27
+ const TOOL_RUNS = [
28
+ [/k8s|kube|cluster|server|node|pod/, STEPS.servers],
29
+ [/gitlab|glab/, "checking GitLab"],
30
+ [/github/, "checking GitHub"],
31
+ [/mongo|postgres|mysql|database|(?:^|-)db(?:-|$)/, "checking the database"],
32
+ [/grafana|prometheus|loki|monitor|metrics|alert/, "checking the monitoring"],
33
+ [/sentry/, "checking the error reports"],
34
+ [/calendar|agenda/, STEPS.calendar],
35
+ [/task|spaces|ticket/, STEPS.tasks],
36
+ [/gmail|mail/, "checking email"],
37
+ [/zoho|crm|books/, "checking Zoho"],
38
+ [/kazee|chat/, "checking Kazee"],
39
+ ];
40
+
41
+ const CLI = String.raw`(?:\bopen-claudia|\bcli\.js)\s+`;
42
+ const CLI_STEPS = [
43
+ [new RegExp(`${CLI}(?:ts|tw|transcript-search|transcript-window|recent)\\b`), STEPS.past],
44
+ [new RegExp(`${CLI}(?:entity|entities|pack|packs|people|recall|lessons|ideas|intros)\\b`), STEPS.notes],
45
+ [new RegExp(`${CLI}tools?\\s+(?:search|show|list)\\b`), STEPS.tool],
46
+ [new RegExp(`${CLI}(?:task|tasks|kpi)\\b`), STEPS.tasks],
47
+ [new RegExp(`${CLI}(?:agenda|cron-list|job-list|watch-list|remind)\\b`), STEPS.calendar],
48
+ ];
49
+
50
+ function commandText(detail) {
51
+ if (typeof detail === "string") return detail;
52
+ const command = detail?.command ?? detail?.cmd ?? "";
53
+ return Array.isArray(command) ? command.join(" ") : String(command || "");
54
+ }
55
+
56
+ const byName = (id) => (TOOL_RUNS.find(([pattern]) => pattern.test(String(id).toLowerCase())) || [null, STEPS.check])[1];
57
+
58
+ // A tool start as a step, or null for bookkeeping (call-say is speech, not a step).
59
+ function stepForTool(name, detail) {
60
+ const tool = String(name || "");
61
+ if (/^(?:Read|Grep|Glob|LS|NotebookRead)$/.test(tool)) return STEPS.files;
62
+ if (/^(?:WebFetch|WebSearch|Web)$/.test(tool)) return STEPS.web;
63
+ if (/^(?:Edit|Write|MultiEdit|NotebookEdit)$/.test(tool)) return STEPS.change;
64
+ if (/^(?:TodoWrite|ToolSearch|ExitPlanMode)$/.test(tool)) return null;
65
+ if (/^(?:Task|Agent)$/.test(tool)) return STEPS.helper;
66
+ if (/^mcp__/.test(tool)) return /search|fetch|browse|web/i.test(tool) && !/mail|calendar|drive/i.test(tool) ? STEPS.web : byName(tool.slice(5));
67
+ if (tool !== "Bash" && tool !== "Shell") return STEPS.check;
68
+ const command = commandText(detail);
69
+ if (/\bcall-(?:say|ask)\b/.test(command)) return null;
70
+ const run = new RegExp(`${CLI}tools?\\s+run\\s+([A-Za-z0-9._-]+)`).exec(command);
71
+ if (run) return byName(run[1]);
72
+ const cli = CLI_STEPS.find(([pattern]) => pattern.test(command));
73
+ if (cli) return cli[1];
74
+ if (/\b(?:kubectl|helm|k9s)\b/.test(command)) return STEPS.servers;
75
+ if (/^\s*(?:cd\s+\S+\s*&&\s*)?git\b/.test(command)) return STEPS.code;
76
+ if (/^\s*(?:cd\s+\S+\s*&&\s*)?(?:cat|head|tail|less|grep|rg|ls|find|wc|sed\s+-n|jq)\b/.test(command)) return STEPS.files;
77
+ if (/^\s*(?:curl|wget)\b/.test(command)) return STEPS.service;
78
+ return STEPS.check;
79
+ }
80
+
81
+ module.exports = { stepForTool, STEPS };