@inetafrica/open-claudia 3.5.75 → 3.5.77

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
+ ## 3.5.77
4
+
5
+ 3.5.76 plus two pieces first run on the Mac build (Sumeet 12 Nov, card 6af54622: one test bot first, then the rest on his word).
6
+
7
+ - **Claude Fable 5.2 in the model picker and price table** (`core/providers/claude.js`, `core/model-prices.js`): `claude-fable-5-2` leads the picker and is billed at its own prices (8 / 40 per million, cache read 0.2, cache write 10 / 16) instead of falling through to the Fable 5 row. Needs Claude Code 2.1.302 or newer when named in full; the image installs the latest Claude Code at build time.
8
+ - **`call --help` never dials** (`bin/cli.js`, `rust/maia-cli/src/main.rs`): `open-claudia call --help` or `-h` prints usage, and an unknown flag is refused with "nothing was dialled". Before, the call verb passed every argument on as a call, so asking for help rang the owner (9 Nov). Fixed in both the Node CLI and the Rust front door.
9
+
10
+ Tests: `test-call-flags.js` (new, in the suite), plus additions to `test-rust-cli-parity.js`, `test-provider-claude.js` and `test-cost-by-part.js`.
11
+
12
+ ## 3.5.76
13
+
14
+ 3.5.75 plus the memory cuts from the usage work, the lessons ranking fix, the empty-lock fix, `handoff --new`, HTML views and the type-check guard (Sumeet 9 Nov: "all bots get all features"). Every new setting is optional: unset, a bot behaves as 3.5.75 except where noted.
15
+
16
+ - **Recall helper sleeps when idle** (`core/recall/warm-walker.js`): the warm recall walker stops after 10 minutes with no walks (about 160 MB back) and starts again on the next one. `RECALL_WARM_WALKER_IDLE_MS` changes the wait; `0` keeps it running.
17
+ - **Usage history read only when needed** (`core/runner.js`): a bot no longer parses its whole usage history after every turn; it reads it only when usage alerts are on.
18
+ - **Node flags for the bot only** (`docker-entrypoint.sh`): `OC_NODE_FLAGS` in a bot's env (for example a heap limit) goes on the main node command line, never `NODE_OPTIONS`, so agents, hooks and CLI runs do not inherit it. Unset, the start command is unchanged.
19
+ - **`RECALL_ENGINE` in the bot's `.env` works** (`core/recall/index.js`): the engine set in `.env` is now honoured, not only one set in the process env. A per-chat `/engine` choice still comes first.
20
+ - **Pack reviews wait their turn** (`core/pack-review.js`): reviews run one at a time per bot behind a queue instead of being dropped when too many came at once. `OC_REVIEW_CONCURRENCY` (default 1) and `OC_REVIEW_QUEUE_MAX` (default 20; the oldest is dropped with a log line) tune it.
21
+ - **Heavy libraries load on demand**: the call library (werift) loads on the first Kazee call and the Telegram library only when a Telegram token is set (about 40 MB less idle). The first call after a start takes a few seconds longer.
22
+ - **Type checks take their turn** (`core/tool-guard.js`): `tsc` now counts in the test-run guard, so only one test run or type check goes at a time on a bot and the free-memory floor applies.
23
+ - **Lessons keep their ranking** (`core/lessons.js`): reading one pack's topic lessons no longer resets every other lesson's ranking to zero.
24
+ - **An old empty migration lock no longer blocks start** (`core/migration-backup.js`): a lock file left empty by a run that died before writing its pid is treated as stale once old, so the bot stops crash-looping on it. A fresh empty lock still counts as held.
25
+ - **`open-claudia handoff --new "<title>" "<message>"`** (`core/thread-handoff.js`): starts a real titled Kazee thread, posts the hand-off in it tagging the person who asked (so it shows in their Threads list), then wakes it. Refused hand-offs start nothing.
26
+ - **HTML views** (`bin/views.js`, `core/views.js`): `open-claudia view send <page.html> --title "<t>" [--handler <file>] [--wake each|none]` sends an interactive page into Kazee Chat as a card with an Open button; it opens walled off (libraries from public CDNs, no data fetched from the internet). What the person does on the page runs the handler or wakes the conversation. `open-claudia view push` sends data back into open pages; `open-claudia view example` prints a quiz page and handler. Kazee Chat only.
27
+
28
+ Tests: `test-walker-idle-stop.js`, `test-usage-history-lazy.js`, `test-node-flags-entrypoint.js`, `test-recall-engine-env.js`, `test-review-queue.js`, `test-lazy-heavy-libs.js`, `test-lessons-meta-keep.js`, `test-views.js` (all in the suite), plus additions to `test-test-run-guard.js`, `test-provider-migration-backup.js` and `test-thread-handoff.js`.
29
+
3
30
  ## 3.5.75
4
31
 
5
32
  3.5.74 plus personal bots act for their owner through a pass (from the Maia build), and the one-tap switch-on card. Nothing changes for a bot until Central has `bot:me` (an older Central answers 404 and the bot stays as it was). Autonomous bots are unchanged.
package/Dockerfile CHANGED
@@ -102,5 +102,7 @@ EXPOSE 8080
102
102
  # Switch to non-root user
103
103
  USER 1001
104
104
 
105
+ # OC_NODE_FLAGS in a bot's env: docker-entrypoint.sh puts those flags on this
106
+ # node command only. Unset, the command below runs as is.
105
107
  ENTRYPOINT ["docker-entrypoint.sh"]
106
108
  CMD ["node", "bin/cli.js", "web"]
package/bin/cli.js CHANGED
@@ -460,6 +460,17 @@ switch (command) {
460
460
  const { postJson } = require("./loopback-client");
461
461
  try {
462
462
  const rest = args.slice(1);
463
+ // `call --help` once rang the owner (9 Nov): help and unknown flags never dial.
464
+ const usage = `Usage: open-claudia call [<userId>] [--about "<why>"]\nRings the owner (or <userId>) in Kazee Chat.`;
465
+ if (rest.includes("--help") || rest.includes("-h")) {
466
+ console.log(usage);
467
+ process.exit(0);
468
+ }
469
+ const unknown = rest.find((arg, i) => arg.startsWith("-") && arg !== "--about" && rest[i - 1] !== "--about");
470
+ if (unknown) {
471
+ console.error(`Unknown option ${unknown}; nothing was dialled.\n${usage}`);
472
+ process.exit(2);
473
+ }
463
474
  const aboutAt = rest.indexOf("--about");
464
475
  const reason = aboutAt >= 0 ? String(rest[aboutAt + 1] || "").trim() : "";
465
476
  const toUserId = String(rest.find((arg, i) => !arg.startsWith("--") && rest[i - 1] !== "--about") || "").trim();
@@ -494,6 +505,10 @@ switch (command) {
494
505
  require("./cards").runDecide(args.slice(1));
495
506
  break;
496
507
  }
508
+ case "view": {
509
+ require("./views").runView(args.slice(1));
510
+ break;
511
+ }
497
512
  case "teach": {
498
513
  Promise.resolve(require("./teach").run(args.slice(1)))
499
514
  .catch((e) => { console.error(`teach command failed: ${e.message}`); process.exitCode = 1; });
@@ -682,6 +697,7 @@ Send tools (only work inside an active bot-spawned task):
682
697
  open-claudia card pieces List the pieces a card is built from (table, tick, date, slots, rows, board...) and their examples
683
698
  open-claudia card update <id> ... Close/open a card you sent, add a note, or replace its spec (card example <name> for specs)
684
699
  open-claudia decide "<q>" --option id="label" ... Queue a decision card for the owner (one at a time; decide list|cancel)
700
+ open-claudia view send <page.html> --title "<t>" [--handler <file>] Send an interactive web page (Kazee Chat); view push|example
685
701
  open-claudia teach list|show|finish|cancel <id> Browser recordings from Teach me; finish deletes the pictures, cancel the whole recording
686
702
 
687
703
  Memory tools:
@@ -726,6 +742,7 @@ Multi-user / cross-channel:
726
742
  open-claudia auth list|revoke <chatId> Raw authorization view + direct deauth
727
743
  open-claudia send-to --person "<name>" "<msg>" Relay a message to another team member
728
744
  open-claudia handoff --thread <id> "<msg>" Post in another Kazee thread and wake it to start work
745
+ open-claudia handoff --new "<title>" "<msg>" Start a titled thread, tag the asker, hand off into it
729
746
  ([--chat <chatId>] [--title "<label>"]; owner only)
730
747
  open-claudia recent --person "<name>" [--limit] Read recent activity from another chat
731
748
 
package/bin/handoff.js CHANGED
@@ -9,8 +9,12 @@ Hand work to another Kazee thread: post the message there (visibly, as the
9
9
  bot) and wake that thread's conversation to start on it now.
10
10
 
11
11
  open-claudia handoff --thread <threadId> "<message>" [--chat <chatId>] [--title "<short label>"]
12
+ open-claudia handoff --new "<thread title>" "<message>" [--chat <chatId>]
12
13
 
13
14
  --thread the target thread's id (its root message id)
15
+ --new start a NEW titled thread (1-80 characters) and hand off into it;
16
+ the person who asked is tagged so it shows in their Threads list.
17
+ Never post a plain main-chat message and hand off onto that.
14
18
  --chat the Kazee chat the thread is in (default: this conversation's chat)
15
19
  --title a short label for the wake-up job (cron-list shows it)
16
20
 
@@ -35,6 +39,7 @@ async function run(argv) {
35
39
  process.exit(args[0] === "--help" || args[0] === "help" ? 0 : 2);
36
40
  }
37
41
  const targetThreadId = takeFlag(args, "thread");
42
+ const newTitle = takeFlag(args, "new");
38
43
  const targetChatId = takeFlag(args, "chat");
39
44
  const title = takeFlag(args, "title");
40
45
  if (args[0] && /^--[a-z]/.test(args[0])) {
@@ -42,16 +47,20 @@ async function run(argv) {
42
47
  process.exit(2);
43
48
  }
44
49
  const message = args.join(" ").trim();
45
- if (!targetThreadId) { console.error("Missing --thread <threadId>."); process.exit(2); }
50
+ if (targetThreadId && newTitle !== null) { console.error('Use --thread <threadId> or --new "<title>", not both.'); process.exit(2); }
51
+ if (!targetThreadId && newTitle === null) { console.error('Missing --thread <threadId> (an existing thread) or --new "<title>" (a new thread).'); process.exit(2); }
52
+ if (newTitle !== null && !newTitle.trim()) { console.error('--new needs a title: --new "<title>".'); process.exit(2); }
46
53
  if (!message) { console.error("Missing message text."); process.exit(2); }
47
54
  try {
48
- const body = { targetThreadId, message };
55
+ const body = { message };
56
+ if (targetThreadId) body.targetThreadId = targetThreadId;
57
+ if (newTitle !== null) body.newThreadTitle = newTitle;
49
58
  if (targetChatId) body.targetChatId = targetChatId;
50
59
  if (title) body.title = title;
51
60
  const res = await postJson("thread-handoff", body);
52
61
  const target = res.target || {};
53
62
  const where = target.title ? `"${target.title}" (${target.threadId})` : `thread ${target.threadId || targetThreadId}`;
54
- console.log(`Handed off to ${where}: posted message ${res.messageId || "(no id)"}, wake job ${res.jobId || "?"} (hand-off ${res.hop || "?"} of ${res.maxHops || 3} in a row).`);
63
+ console.log(`Handed off to ${res.newThread ? "new thread " : ""}${where}: posted message ${res.messageId || "(no id)"}, wake job ${res.jobId || "?"} (hand-off ${res.hop || "?"} of ${res.maxHops || 3} in a row).`);
55
64
  process.exit(0);
56
65
  } catch (e) {
57
66
  console.error(e.message);
package/bin/views.js ADDED
@@ -0,0 +1,255 @@
1
+ // `open-claudia view ...`: HTML views in Kazee Chat from inside a turn. A
2
+ // view is a web page sent as a card with an Open button; it opens in a
3
+ // walled-off panel and talks back with kazee.send / kazee.on (core/views.js
4
+ // answers its events).
5
+
6
+ const fs = require("fs");
7
+ const path = require("path");
8
+ const { postJson } = require("./loopback-client");
9
+
10
+ const FILE_MAX = 2 * 1024 * 1024;
11
+ const PUSH_MAX = 16384;
12
+
13
+ const VIEW_USAGE = `Usage:
14
+ open-claudia view send <page.html> --title "<title>" [--body "<text>"] [--label "<button>"]
15
+ [--preview <picture.png|.jpg>] [--handler <file>] [--wake each|none]
16
+ open-claudia view push <messageId> <json | -> [--to <userId>]
17
+ open-claudia view example [page|handler]
18
+ Views are Kazee Chat only. The page (.html, at most 2 MB) opens in a walled-off
19
+ panel: it may load scripts, styles and fonts from cdn.jsdelivr.net, unpkg.com,
20
+ cdnjs.cloudflare.com and esm.sh, but cannot fetch anything from the internet,
21
+ so put its data in the page. In the page:
22
+ kazee.send(name, data) send an event to you (name: a-z 0-9 _ . -, data under 4 KB as JSON)
23
+ kazee.on(data => ...) receive what you push (view push, or a handler's {"push": ...})
24
+ Events wake this conversation (--wake each, the default; events close together
25
+ come as one wake) or are dropped (--wake none). With --handler, a script runs
26
+ once per event instead (node for .js, else run directly; 10 s; its folder is the
27
+ working directory). It gets the event as JSON on stdin
28
+ {"messageId","chatId","userId","userName","name","data","at"}
29
+ and each line it prints that is JSON is an order:
30
+ {"push": <data>, "to": "<userId>"} back to the page ("to" optional: only that person)
31
+ {"wake": "<text>"} wake this conversation
32
+ Never put answers you check (a quiz key) in the page: people can read it. Keep them in the handler.`;
33
+
34
+ const EXAMPLE_PAGE = `<!doctype html>
35
+ <html>
36
+ <head>
37
+ <meta charset="utf-8">
38
+ <meta name="viewport" content="width=device-width, initial-scale=1">
39
+ <title>Quick quiz</title>
40
+ <style>
41
+ body { font: 16px/1.4 system-ui, sans-serif; margin: 0; padding: 16px; background: #f7f7f8; color: #1d1d1f; }
42
+ .q { background: #fff; border-radius: 12px; padding: 14px; margin-bottom: 12px; box-shadow: 0 1px 2px rgba(0,0,0,.08); }
43
+ .q h3 { margin: 0 0 10px; font-size: 16px; }
44
+ button { display: block; width: 100%; text-align: left; margin: 6px 0; padding: 10px 12px; border: 1px solid #d0d0d6;
45
+ border-radius: 8px; background: #fff; font: inherit; cursor: pointer; }
46
+ button:disabled { cursor: default; opacity: .7; }
47
+ button.right { border-color: #2e9d57; background: #e7f6ec; opacity: 1; }
48
+ button.wrong { border-color: #d0453b; background: #fbeceb; opacity: 1; }
49
+ .note { font-size: 14px; color: #555; min-height: 1.2em; }
50
+ #score { font-weight: 600; text-align: center; }
51
+ </style>
52
+ </head>
53
+ <body>
54
+ <div id="quiz"></div>
55
+ <p id="score"></p>
56
+ <script>
57
+ // The questions only. The right answers stay in the handler: anyone can read this page.
58
+ const QUESTIONS = [
59
+ { id: "q1", text: "Which planet is closest to the sun?", options: ["Venus", "Mercury", "Mars"] },
60
+ { id: "q2", text: "How many legs does a spider have?", options: ["6", "8", "10"] },
61
+ { id: "q3", text: "What is the boiling point of water at sea level?", options: ["90 °C", "100 °C", "120 °C"] },
62
+ ];
63
+ const results = {};
64
+ const quiz = document.getElementById("quiz");
65
+ for (const q of QUESTIONS) {
66
+ const box = document.createElement("div");
67
+ box.className = "q";
68
+ box.innerHTML = "<h3></h3><div class='opts'></div><div class='note'></div>";
69
+ box.querySelector("h3").textContent = q.text;
70
+ q.options.forEach((label, i) => {
71
+ const b = document.createElement("button");
72
+ b.textContent = label;
73
+ b.onclick = () => {
74
+ box.querySelectorAll("button").forEach((x) => { x.disabled = true; });
75
+ b.dataset.picked = "1";
76
+ box.querySelector(".note").textContent = "Checking...";
77
+ window.kazee.send("answer", { q: q.id, choice: i });
78
+ };
79
+ box.querySelector(".opts").appendChild(b);
80
+ });
81
+ box.id = q.id;
82
+ quiz.appendChild(box);
83
+ }
84
+ // The bot's replies: { q, correct, right, note } for each answer.
85
+ window.kazee.on((data) => {
86
+ if (!data || !data.q) return;
87
+ const box = document.getElementById(data.q);
88
+ if (!box) return;
89
+ const buttons = box.querySelectorAll("button");
90
+ buttons.forEach((b, i) => {
91
+ if (i === data.right) b.className = "right";
92
+ else if (b.dataset.picked) b.className = "wrong";
93
+ });
94
+ box.querySelector(".note").textContent = data.note || "";
95
+ results[data.q] = !!data.correct;
96
+ const done = Object.keys(results).length;
97
+ if (done === QUESTIONS.length) {
98
+ const score = Object.values(results).filter(Boolean).length;
99
+ document.getElementById("score").textContent = "You got " + score + " of " + QUESTIONS.length + ".";
100
+ window.kazee.send("done", { score, of: QUESTIONS.length });
101
+ }
102
+ });
103
+ </script>
104
+ </body>
105
+ </html>
106
+ `;
107
+
108
+ const EXAMPLE_HANDLER = `// handler.js: answers the quiz page's events. Sent with
109
+ // open-claudia view send quiz.html --title "Quick quiz" --handler handler.js
110
+ // It runs once per event: the event is JSON on stdin, each JSON line printed is an order.
111
+ const KEY = { q1: { right: 1, note: "Mercury is closest." },
112
+ q2: { right: 1, note: "Spiders have 8 legs." },
113
+ q3: { right: 1, note: "100 °C at sea level." } };
114
+
115
+ let input = "";
116
+ process.stdin.on("data", (c) => { input += c; });
117
+ process.stdin.on("end", () => {
118
+ const ev = JSON.parse(input);
119
+ const out = (order) => console.log(JSON.stringify(order));
120
+ if (ev.name === "answer" && ev.data && KEY[ev.data.q]) {
121
+ const k = KEY[ev.data.q];
122
+ // Only to the person who answered: others with the page open keep their own quiz.
123
+ out({ push: { q: ev.data.q, correct: ev.data.choice === k.right, right: k.right, note: k.note }, to: ev.userId });
124
+ } else if (ev.name === "done" && ev.data) {
125
+ // Wake the conversation once, when someone finishes.
126
+ out({ wake: \`\${ev.userName || "Someone"} finished the quiz: \${Number(ev.data.score)} of \${Number(ev.data.of)}.\` });
127
+ }
128
+ });
129
+ `;
130
+
131
+ function flag(args, name) {
132
+ const i = args.indexOf(name);
133
+ if (i < 0) return undefined;
134
+ const v = args[i + 1];
135
+ if (v === undefined || v.startsWith("--")) throw new Error(`${name} needs a value`);
136
+ args.splice(i, 2);
137
+ return v;
138
+ }
139
+
140
+ function readFileFor(file, what, exts) {
141
+ const full = path.resolve(file);
142
+ const ext = path.extname(full).toLowerCase();
143
+ if (!exts.includes(ext)) throw new Error(`the ${what} must be a ${exts.join(" or ")} file: ${file}`);
144
+ let st;
145
+ try { st = fs.statSync(full); } catch (_) { throw new Error(`${what} not found: ${file}`); }
146
+ if (!st.isFile()) throw new Error(`${what} is not a file: ${file}`);
147
+ if (st.size === 0) throw new Error(`the ${what} is empty: ${file}`);
148
+ if (st.size > FILE_MAX) throw new Error(`the ${what} is over 2 MB: ${file}`);
149
+ return { fileName: path.basename(full), data: fs.readFileSync(full).toString("base64") };
150
+ }
151
+
152
+ /** `view send` arguments into the loopback body (throws on bad input). */
153
+ function parseSend(argv) {
154
+ const args = [...argv];
155
+ const title = flag(args, "--title");
156
+ const body = flag(args, "--body");
157
+ const label = flag(args, "--label");
158
+ const preview = flag(args, "--preview");
159
+ const handler = flag(args, "--handler");
160
+ const wake = flag(args, "--wake");
161
+ const unknown = args.find((a) => a.startsWith("--"));
162
+ if (unknown) throw new Error(`unknown option ${unknown}`);
163
+ if (args.length !== 1) throw new Error(args.length ? `one page only (got ${args.join(" ")})` : "missing the page (.html file)");
164
+ if (!title || !title.trim()) throw new Error("--title is required");
165
+ if (title.trim().length > 140) throw new Error("--title is over 140 characters");
166
+ if (body && body.length > 600) throw new Error("--body is over 600 characters");
167
+ if (label && label.trim().length > 40) throw new Error("--label is over 40 characters");
168
+ if (wake !== undefined && !["each", "none"].includes(wake)) throw new Error("--wake must be each or none");
169
+ let handlerPath = null;
170
+ if (handler) {
171
+ handlerPath = path.resolve(handler);
172
+ let st = null;
173
+ try { st = fs.statSync(handlerPath); } catch (_) {}
174
+ if (!st || !st.isFile()) throw new Error(`handler not found: ${handler}`);
175
+ if (!/\.(c|m)?js$/i.test(handlerPath)) {
176
+ try { fs.accessSync(handlerPath, fs.constants.X_OK); }
177
+ catch (_) { throw new Error(`the handler is not a .js file and not executable (chmod +x it): ${handler}`); }
178
+ }
179
+ }
180
+ return {
181
+ title: title.trim(),
182
+ ...(body ? { body } : {}),
183
+ ...(label ? { label: label.trim() } : {}),
184
+ ...(wake ? { wake } : {}),
185
+ handler: handlerPath,
186
+ page: readFileFor(args[0], "page", [".html", ".htm"]),
187
+ ...(preview ? { preview: readFileFor(preview, "preview", [".png", ".jpg", ".jpeg"]) } : {}),
188
+ };
189
+ }
190
+
191
+ /** `view push` arguments into the loopback body; `readStdin` for "-". */
192
+ function parsePush(argv, readStdin = () => fs.readFileSync(0, "utf8")) {
193
+ const args = [...argv];
194
+ const to = flag(args, "--to");
195
+ const unknown = args.find((a) => a.startsWith("--"));
196
+ if (unknown) throw new Error(`unknown option ${unknown}`);
197
+ const [messageId, raw] = args;
198
+ if (!messageId) throw new Error("missing the view's message id");
199
+ if (raw === undefined) throw new Error("missing the data (JSON, or - for stdin)");
200
+ if (args.length > 2) throw new Error("too many arguments (quote the JSON)");
201
+ const text = raw === "-" ? readStdin() : raw;
202
+ let data;
203
+ try { data = JSON.parse(text); }
204
+ catch (_) { throw new Error(`the data is not JSON (a string needs quotes: '"hello"')`); }
205
+ if (Buffer.byteLength(JSON.stringify(data)) > PUSH_MAX) throw new Error("the data is over 16 KB as JSON");
206
+ return { messageId, data, ...(to ? { to } : {}) };
207
+ }
208
+
209
+ function exampleText(which) {
210
+ if (which === "page") return EXAMPLE_PAGE;
211
+ if (which === "handler") return EXAMPLE_HANDLER;
212
+ if (which) throw new Error("examples: page, handler (or nothing for both)");
213
+ return [
214
+ "# quiz.html (open-claudia view example page > quiz.html)",
215
+ EXAMPLE_PAGE,
216
+ "# handler.js (open-claudia view example handler > handler.js)",
217
+ EXAMPLE_HANDLER,
218
+ '# Send it: open-claudia view send quiz.html --title "Quick quiz" --handler handler.js',
219
+ "",
220
+ ].join("\n");
221
+ }
222
+
223
+ function fail(msg, code = 1) {
224
+ console.error(msg);
225
+ process.exit(code);
226
+ }
227
+
228
+ async function runView(argv) {
229
+ const args = [...argv];
230
+ const sub = args.shift();
231
+ try {
232
+ if (sub === "example") {
233
+ process.stdout.write(exampleText(args[0]));
234
+ return;
235
+ }
236
+ if (sub === "send") {
237
+ const body = parseSend(args);
238
+ const res = await postJson("view-send", body);
239
+ const events = res.handler ? `events run ${res.handler}` : `events wake this conversation: ${res.wake}`;
240
+ console.log(`Sent view ${res.messageId} (${events})`);
241
+ return;
242
+ }
243
+ if (sub === "push") {
244
+ const body = parsePush(args);
245
+ await postJson("view-push", body);
246
+ console.log(`Pushed to view ${body.messageId}${body.to ? ` (only to ${body.to})` : ""}`);
247
+ return;
248
+ }
249
+ fail(VIEW_USAGE, sub && sub !== "help" && sub !== "--help" ? 1 : 0);
250
+ } catch (e) {
251
+ fail(`view ${sub}: ${e.message}`);
252
+ }
253
+ }
254
+
255
+ module.exports = { runView, parseSend, parsePush, exampleText, VIEW_USAGE, EXAMPLE_PAGE, EXAMPLE_HANDLER };
@@ -546,6 +546,25 @@ class KazeeAdapter {
546
546
  raw: payload,
547
547
  });
548
548
  });
549
+ // HTML views: someone in the chat sent an event from a view page this bot
550
+ // sent (kazee.send in the page). Routed like a card answer; core/views.js
551
+ // runs the view's handler or wakes the conversation the view came from.
552
+ socket.on("view:event", (payload) => {
553
+ if (!payload || !payload.messageId) return;
554
+ const userId = payload.userId ? String(payload.userId) : "";
555
+ this._emit("action", {
556
+ adapter: this,
557
+ channelId: String(payload.chatId || ""),
558
+ canonicalUserId: userId ? canonicalForChannel("kazee", userId) : null,
559
+ userId: userId || null,
560
+ type: "action",
561
+ messageId: payload.messageId,
562
+ threadId: payload.threadRoot ? String(payload.threadRoot) : null,
563
+ action: { buttonId: `kv:${payload.messageId}`, payload: `kv:${payload.messageId}`, sourceMessageId: payload.messageId },
564
+ view: payload,
565
+ raw: payload,
566
+ });
567
+ });
549
568
  socket.on("message:action", (payload) => {
550
569
  const channelId = String(payload.chatId);
551
570
  const known = this._sentMessages.has(String(payload.messageId || ""));
@@ -733,6 +752,18 @@ class KazeeAdapter {
733
752
  // What the bot reads to name a thread: the root's text, the first replies
734
753
  // (the bot's own marked) and the title the thread already has, when the
735
754
  // server sends one. Throws like _fetchThread.
755
+ // Start a real thread (chat-central startThread): a titled header root the
756
+ // chat's bots never hear. Followers are the starter plus whoever the first
757
+ // reply tags, so callers tag the person the thread is for. Resolves the
758
+ // root message id; throws when chat-central refuses.
759
+ async startThread(chatId, title) {
760
+ const route = `/thread/${encodeURIComponent(String(chatId))}/start`;
761
+ const res = await withTimeout(this._request("POST", route, { userId: String(this.botUserId), title: String(title || "") }), THREAD_TITLE_TIMEOUT_MS, "Kazee thread start");
762
+ const root = res && ((res.message && res.message._id) || (res.data && res.data._id));
763
+ if (!root) throw new Error(`Kazee thread start gave no root id${res && res.error ? `: ${res.error}` : ""}`);
764
+ return String(root);
765
+ }
766
+
736
767
  async threadTitleSource(chatId, rootId) {
737
768
  const data = await this._fetchThread(String(chatId), String(rootId), THREAD_TITLE_SOURCE_LIMIT);
738
769
  const root = data.root && typeof data.root === "object" ? data.root : null;
@@ -1435,6 +1466,9 @@ class KazeeAdapter {
1435
1466
  };
1436
1467
  if (opts.replyTo) body.replyTo = opts.replyTo;
1437
1468
  if (buttons) body.interactive = { buttons };
1469
+ // People tagged as @[Name](id) in the text (a hand-off into a new thread
1470
+ // tags whoever asked, so it shows in their Threads list and as unread).
1471
+ if (Array.isArray(opts.mentions) && opts.mentions.length) body.mentions = opts.mentions.map(String);
1438
1472
  const threadId = opts.threadId ? String(opts.threadId) : null;
1439
1473
  if (threadId) body.threadRootId = threadId;
1440
1474
  // Tags of other bots (./bot-handoffs.js). chat-central reads mentions out
@@ -1574,6 +1608,32 @@ class KazeeAdapter {
1574
1608
  return view;
1575
1609
  }
1576
1610
 
1611
+ // Upload one file as chat media (chat-central `uploadMedia`) and answer its
1612
+ // media id. For HTML views: the page and its preview picture go up first,
1613
+ // then the card names them by id. Throws when the server keeps no id.
1614
+ async uploadMedia(channelId, { buffer, fileName, contentType } = {}) {
1615
+ const upload = await this._uploadMultipart(
1616
+ `/chat/media/${encodeURIComponent(channelId)}`,
1617
+ { media: { buffer, fileName, contentType } },
1618
+ );
1619
+ const saved = Array.isArray(upload?.media) ? upload.media[0] : null;
1620
+ if (!saved || !saved._id) {
1621
+ const why = Array.isArray(upload?.errors) && upload.errors[0]?.error ? `: ${upload.errors[0].error}` : "";
1622
+ throw new Error(`Kazee Chat kept no media id for ${fileName}${why}`);
1623
+ }
1624
+ return String(saved._id);
1625
+ }
1626
+
1627
+ // HTML views: hand data to an open view page (kazee.on in the page).
1628
+ // Only the message's sender may push; `to` limits it to one person.
1629
+ async pushView(messageId, data, to) {
1630
+ const body = { data: data === undefined ? null : data };
1631
+ if (to) body.to = String(to);
1632
+ const res = await this._request("PUT", `/message/${encodeURIComponent(messageId)}/view/push`, body);
1633
+ if (res && res.success === false) throw new Error(res.error || "view push refused");
1634
+ return true;
1635
+ }
1636
+
1577
1637
  // A card as the server shows it now (its state: open, closed, decided),
1578
1638
  // read through the one-message lookup. Null when the message is gone.
1579
1639
  async readCard(chatId, messageId) {
@@ -2083,7 +2143,7 @@ class KazeeAdapter {
2083
2143
  const boundary = `----oc-${Date.now()}-${Math.random().toString(36).slice(2)}`;
2084
2144
  const parts = [];
2085
2145
  for (const [field, info] of Object.entries(files)) {
2086
- parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${field}"; filename="${info.fileName}"\r\nContent-Type: application/octet-stream\r\n\r\n`));
2146
+ parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${field}"; filename="${info.fileName}"\r\nContent-Type: ${info.contentType || "application/octet-stream"}\r\n\r\n`));
2087
2147
  parts.push(info.buffer);
2088
2148
  parts.push(Buffer.from("\r\n"));
2089
2149
  }
@@ -11,7 +11,6 @@ const fs = require("fs");
11
11
  const os = require("os");
12
12
  const path = require("path");
13
13
  const { spawn } = require("child_process");
14
- const { dePacketizeRtpPackets } = require("werift");
15
14
 
16
15
  const STILL_EVERY_S = 2;
17
16
  const STILL_SIZE = 768;
@@ -152,7 +151,7 @@ class CameraStills {
152
151
  if ((packets[index].header.sequenceNumber - packets[index - 1].header.sequenceNumber + 65536) % 65536 !== 1) return this._lost();
153
152
  }
154
153
  let frame;
155
- try { frame = dePacketizeRtpPackets("VP8", packets); } catch (_) { return this._lost(); }
154
+ try { frame = require("werift").dePacketizeRtpPackets("VP8", packets); } catch (_) { return this._lost(); }
156
155
  if (!frame?.data?.length) return;
157
156
  const size = frame.isKeyframe ? vp8KeyframeSize(frame.data) : null;
158
157
  if (this._needKeyframe) {
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
 
3
- const { MediaStreamTrack, RTCPeerConnection, RTCRtpHeaderExtensionParameters, RtpHeader, RtpPacket } = require("werift");
3
+ // Loaded on the first call, so a bot that takes no calls never holds this library.
4
+ const werift = () => require("werift");
4
5
  const { JanusClient } = require("./janus-client");
5
6
  const { projectAssignedIce, supportedIceServers } = require("./access");
6
7
  const { AdaptiveTurnDetector, OpusAudioCodec, WEBRTC_SAMPLES, downsample48To16, upsampleTo48, rmsDbfs } = require("./audio");
@@ -227,7 +228,7 @@ class KazeeCallSession {
227
228
  constructor({ roomId, chatId, callerId, botUserId, janusUrl, janusToken, janusTokenExpiresAt, roomToken, roomTokenExpiresAt, managedRoom = false, refreshRoomAccess, iceServers = [], iceCredentialsExpiresAt, onTranscript, onEnded, onMediaReady,
228
229
  maxCallMs, idleMs, checkInMs, checkInGraceMs, dropMs, dropGiveUpMs, humDb, finalWaitMs, synthesize = synthesizePcm, synthesizeStream = null, asCaller = false, greeting = null,
229
230
  turnDecision = detectTurn, acknowledgement = selectAcknowledgement, onSpeechStart, onSpeechNoise, onEarlyWords, onUtteranceDropped,
230
- JanusClientImpl = JanusClient, PeerConnectionImpl = RTCPeerConnection, SpeechStreamImpl = SpeechStream } = {}) {
231
+ JanusClientImpl = JanusClient, PeerConnectionImpl = werift().RTCPeerConnection, SpeechStreamImpl = SpeechStream } = {}) {
231
232
  this.roomId = Number(roomId);
232
233
  this.chatId = String(chatId);
233
234
  this.callerId = String(callerId);
@@ -357,7 +358,7 @@ class KazeeCallSession {
357
358
  // A feed's connection also takes the caller's camera; with the
358
359
  // orientation extension a phone held upright gives an upright still.
359
360
  const pc = new this.PeerConnectionImpl(camera
360
- ? { iceServers: this.iceServers, headerExtensions: { audio: [], video: [new RTCRtpHeaderExtensionParameters({ id: 13, uri: VIDEO_ORIENTATION_URI })] } }
361
+ ? { iceServers: this.iceServers, headerExtensions: { audio: [], video: [new (werift().RTCRtpHeaderExtensionParameters)({ id: 13, uri: VIDEO_ORIENTATION_URI })] } }
361
362
  : { iceServers: this.iceServers });
362
363
  if (Number.isFinite(expiresAt)) {
363
364
  this._transportIceExpiry.set(pc, expiresAt);
@@ -515,7 +516,7 @@ class KazeeCallSession {
515
516
  this._maybeGreet();
516
517
  }
517
518
  });
518
- const track = new MediaStreamTrack({ kind: "audio" });
519
+ const track = new (werift().MediaStreamTrack)({ kind: "audio" });
519
520
  const sender = pc.addTrack(track);
520
521
  this.publisher = { pc, track, sender, pending: [], remoteSet: false };
521
522
  const offer = await pc.createOffer();
@@ -1046,7 +1047,7 @@ class KazeeCallSession {
1046
1047
  if (frame.length < WEBRTC_SAMPLES * 2) frame = Buffer.concat([frame, Buffer.alloc(WEBRTC_SAMPLES * 2 - frame.length)]);
1047
1048
  const opus = this.codec.encode(frame);
1048
1049
  const payloadType = this.publisher?.sender?.codec?.payloadType || 111;
1049
- this.publisher?.track?.writeRtp(new RtpPacket(new RtpHeader({
1050
+ this.publisher?.track?.writeRtp(new (werift().RtpPacket)(new (werift().RtpHeader)({
1050
1051
  payloadType, sequenceNumber: this.sequence++ & 0xffff, timestamp: this.timestamp >>> 0, ssrc: this.ssrc,
1051
1052
  }), opus));
1052
1053
  this.timestamp = (this.timestamp + WEBRTC_SAMPLES) >>> 0;
@@ -6,7 +6,6 @@
6
6
  const fs = require("fs");
7
7
  const path = require("path");
8
8
  const https = require("https");
9
- const TelegramBot = require("node-telegram-bot-api");
10
9
  const { TEMP_DIR, FILES_DIR, CONFIG_DIR } = require("../../core/config");
11
10
  const { safeJoin } = require("../../core/safe-filename");
12
11
  const { canonicalForChannel } = require("../../core/identity");
@@ -27,6 +26,8 @@ class TelegramAdapter {
27
26
  // one bot) sidesteps the stale-socket trap that drove the old restart storm.
28
27
  // We still own the agent so a heal can destroy any lingering socket.
29
28
  this._agent = new https.Agent({ keepAlive: false });
29
+ // Loaded here so a bot with no Telegram token never holds this library.
30
+ const TelegramBot = require("node-telegram-bot-api");
30
31
  this.bot = new TelegramBot(token, {
31
32
  polling: { autoStart: false, params: { timeout: 30 } },
32
33
  // Socket-idle timeout on every request (getUpdates included). With
package/core/actions.js CHANGED
@@ -148,6 +148,14 @@ function appendMandateLine(rec, lineText, log, opts) {
148
148
  }
149
149
 
150
150
  async function handleAction(envelope) {
151
+ // HTML views: an event from a view page this bot sent (channels/kazee
152
+ // adapter, view:event). core/views.js runs its handler or wakes the
153
+ // conversation it came from; nothing is sent into the chat from here.
154
+ if (envelope.view) {
155
+ try { await require("./views").handleViewEvent(envelope.view, { adapter: envelope.adapter }); }
156
+ catch (e) { console.error("[views] handling an event failed:", e.message); }
157
+ return;
158
+ }
151
159
  // Kazee Cards: an answer to a card this bot sent (channels/kazee adapter,
152
160
  // card:response). The card ledger decides what it wakes; nothing is sent
153
161
  // into the chat from here.
package/core/lessons.js CHANGED
@@ -184,7 +184,8 @@ function topicLessons(dir, { exclude = null, max = 3, maxChars = 900 } = {}) {
184
184
  const lessons = readLessons().filter((l) => l.src === dir);
185
185
  if (lessons.length === 0) return [];
186
186
  const skip = exclude || new Set(injectedLessons().map((l) => l.id));
187
- const meta = reconcileMeta(lessons);
187
+ // The full list: reconciling just this pack would drop every other lesson's meta.
188
+ const meta = reconcileMeta();
188
189
  const ranked = lessons.filter((l) => !skip.has(l.id)).sort((a, b) => {
189
190
  const ma = meta[a.id] || {}, mb = meta[b.id] || {};
190
191
  return (mb.reinforced || 0) - (ma.reinforced || 0)