@inetafrica/open-claudia 3.5.74 → 3.5.76

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,36 @@
1
1
  # Changelog
2
2
 
3
+ ## 3.5.76
4
+
5
+ 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.
6
+
7
+ - **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.
8
+ - **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.
9
+ - **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.
10
+ - **`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.
11
+ - **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.
12
+ - **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.
13
+ - **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.
14
+ - **Lessons keep their ranking** (`core/lessons.js`): reading one pack's topic lessons no longer resets every other lesson's ranking to zero.
15
+ - **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.
16
+ - **`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.
17
+ - **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.
18
+
19
+ 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`.
20
+
21
+ ## 3.5.75
22
+
23
+ 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.
24
+
25
+ - **Type check** (`core/bot-profile.js`): on start, and every 15 minutes, the bot asks Central `GET /api/bots/me` with its own key for its kind, its one owner and the owner's consent (ticked apps, end date, `renewDue`). The answer is kept in `bot-profile.json`, so a restart while Central is down still knows.
26
+ - **One owner**: a personal bot's owner is the person in Central. Her id replaces `KAZEE_OWNER_USER_ID`, from config load and on every refresh. `access.isChatOwner` counts no other owner (no people records, `auth.json` or chat ids marked owner), and the web dashboard and the voice bridge reach her rather than being owners themselves.
27
+ - **One pass helper** (`core/app-pass.js`): every app call of a personal bot trades its key for a 10-minute pass for that app (`POST /api/bots/pass`), reused until a minute before it ends, one request per app at a time; the bot's own key is never sent to an app. Kazee Chat keeps the bot's own key. The owner reads each app's own name (Spaces, CRM, Tickets, Agent Space, Infrastructure) for Central's exact app names.
28
+ - **Spaces**: the conversational channel stays idle for a personal bot (no socket of its own); anything still sent through it, and the connected-apps plane watching the apps the owner ticked, goes out with her pass.
29
+ - **One-tap card** (SEC-28, `core/owner-setup.js`): when an app refuses a pass because she has not switched the bot on there, and a week before her consent ends, the owner gets a Kazee Chat card with one button that opens Central at the form (Switch me on / Renew); at most once a day per app and reason. `open-claudia owner-setup [--apps a,b] [--renew]` sends it on request; `open-claudia pass <app>` prints a fresh pass, never the key.
30
+ - **Instructions**: a personal bot gets its own block in the core instructions (who it works for, approvals, money and deletes need her tap, send the one-tap card).
31
+
32
+ Tests: `test-bot-acts-for-owner.js` (in the suite).
33
+
3
34
  ## 3.5.74
4
35
 
5
36
  3.5.73 plus the Rust command helper, released on Sumeet's card of 8 Nov ("Yes, Maya Cloud first, then one bot at a time") after 3 clean days on his Mac (about 62,000 commands, no crashes).
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
@@ -218,6 +218,41 @@ switch (command) {
218
218
  break;
219
219
  }
220
220
 
221
+ case "pass": {
222
+ // A fresh pass for one app (core/app-pass.js), for a tool that calls an
223
+ // app for a personal bot's owner: e.g. `open-claudia pass kazee-spaces-backend`.
224
+ // Never prints the bot's own key: an autonomous bot has no pass to give.
225
+ const applicationName = args[1];
226
+ if (!applicationName) {
227
+ console.error("Usage: open-claudia pass <Central application name, e.g. kazee-spaces-backend>");
228
+ process.exit(2);
229
+ }
230
+ (async () => {
231
+ const botProfile = require(path.join(botDir, "core/bot-profile"));
232
+ const appPass = require(path.join(botDir, "core/app-pass"));
233
+ await botProfile.refresh();
234
+ if (!botProfile.isPersonal()) {
235
+ console.error("Only a personal bot uses passes; this bot calls apps with its own key.");
236
+ process.exit(2);
237
+ }
238
+ try {
239
+ process.stdout.write(await appPass.tokenFor(applicationName));
240
+ process.stdout.write("\n");
241
+ process.exit(0);
242
+ } catch (e) {
243
+ console.error(e instanceof appPass.PassRefused ? e.ownerMessage : e.message);
244
+ process.exit(1);
245
+ }
246
+ })();
247
+ break;
248
+ }
249
+
250
+ case "owner-setup": {
251
+ // The one-tap card for a personal bot's owner (core/owner-setup.js).
252
+ require("./owner-setup").run(args.slice(1)).then((code) => process.exit(code));
253
+ break;
254
+ }
255
+
221
256
  case "health": {
222
257
  console.log("Running health checks...\n");
223
258
  const verbose = args.includes("-v") || args.includes("--verbose");
@@ -459,6 +494,10 @@ switch (command) {
459
494
  require("./cards").runDecide(args.slice(1));
460
495
  break;
461
496
  }
497
+ case "view": {
498
+ require("./views").runView(args.slice(1));
499
+ break;
500
+ }
462
501
  case "teach": {
463
502
  Promise.resolve(require("./teach").run(args.slice(1)))
464
503
  .catch((e) => { console.error(`teach command failed: ${e.message}`); process.exitCode = 1; });
@@ -647,6 +686,7 @@ Send tools (only work inside an active bot-spawned task):
647
686
  open-claudia card pieces List the pieces a card is built from (table, tick, date, slots, rows, board...) and their examples
648
687
  open-claudia card update <id> ... Close/open a card you sent, add a note, or replace its spec (card example <name> for specs)
649
688
  open-claudia decide "<q>" --option id="label" ... Queue a decision card for the owner (one at a time; decide list|cancel)
689
+ open-claudia view send <page.html> --title "<t>" [--handler <file>] Send an interactive web page (Kazee Chat); view push|example
650
690
  open-claudia teach list|show|finish|cancel <id> Browser recordings from Teach me; finish deletes the pictures, cancel the whole recording
651
691
 
652
692
  Memory tools:
@@ -679,12 +719,19 @@ Background work (only inside an active bot-spawned task):
679
719
  Cluster control (self-management of this bot's own deployment via the broker):
680
720
  open-claudia cluster status|logs|restart|start|stop|scale <0|1>|sync
681
721
 
722
+ Personal bots (acting for their owner):
723
+ open-claudia pass <app> A fresh 10-minute pass to act as your owner at one app
724
+ (e.g. kazee-spaces-backend); never your own key
725
+ open-claudia owner-setup [--apps a,b] [--renew] Send your owner a one-tap card in Kazee Chat to switch
726
+ you on (default: apps not switched on yet) or renew
727
+
682
728
  Multi-user / cross-channel:
683
729
  open-claudia people list|show|add|note|link Roster of known team members
684
730
  open-claudia intros list|approve|reject Pending introductions from unknown chats
685
731
  open-claudia auth list|revoke <chatId> Raw authorization view + direct deauth
686
732
  open-claudia send-to --person "<name>" "<msg>" Relay a message to another team member
687
733
  open-claudia handoff --thread <id> "<msg>" Post in another Kazee thread and wake it to start work
734
+ open-claudia handoff --new "<title>" "<msg>" Start a titled thread, tag the asker, hand off into it
688
735
  ([--chat <chatId>] [--title "<label>"]; owner only)
689
736
  open-claudia recent --person "<name>" [--limit] Read recent activity from another chat
690
737
 
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);
@@ -0,0 +1,56 @@
1
+ // open-claudia owner-setup — send a personal bot's owner the one-tap card that opens
2
+ // Central at the form to switch the bot on (or renew it). See
3
+ // core/owner-setup.js. The running bot sends it to the owner's direct Kazee chat.
4
+
5
+ const path = require("path");
6
+
7
+ const HELP = `
8
+ Send your owner a one-tap card to switch you on, or to renew.
9
+
10
+ open-claudia owner-setup the apps you can work in that your owner hasn't switched on yet
11
+ open-claudia owner-setup --apps Tickets just these apps (Central names or Tickets, CRM, Spaces...)
12
+ open-claudia owner-setup --renew renew before your owner's permission ends
13
+
14
+ Personal bots only. You can never switch yourself on: only your owner can confirm.
15
+ `;
16
+
17
+ function takeFlag(args, name) {
18
+ const idx = args.indexOf(`--${name}`);
19
+ if (idx < 0) return null;
20
+ const v = args[idx + 1];
21
+ args.splice(idx, 2);
22
+ return v;
23
+ }
24
+
25
+ // Resolves the exit code. deps (tests): refresh, post, log, error.
26
+ async function run(args, deps = {}) {
27
+ const log = deps.log || console.log;
28
+ const error = deps.error || console.error;
29
+ args = [...args];
30
+ if (args[0] === "--help" || args[0] === "help") { log(HELP); return 0; }
31
+ const botProfile = require(path.join(__dirname, "..", "core", "bot-profile"));
32
+ const ownerSetup = require(path.join(__dirname, "..", "core", "owner-setup"));
33
+ const renew = args.includes("--renew");
34
+ const apps = String(takeFlag(args, "apps") || "").split(",").map((s) => s.trim()).filter(Boolean);
35
+ try {
36
+ await (deps.refresh || botProfile.refresh)();
37
+ } catch (_) { /* the last answer on disk */ }
38
+ const planned = ownerSetup.plan({ apps, renew });
39
+ if (planned.refused) { error(planned.refused); return 2; }
40
+ if (planned.nothing) { log(planned.nothing); return 0; }
41
+ try {
42
+ const post = deps.post || require("./loopback-client").postJson;
43
+ const res = await post("owner-setup", { apps, renew });
44
+ if (res.nothing) { log(res.nothing); return 0; }
45
+ const who = botProfile.ownerFirstName();
46
+ if (res.sent === "already-sent") log(`${who} already got this card in the last day; not sent again.`);
47
+ else if (res.sent === "card") log(`Sent ${who} a card in Kazee Chat: "${res.title}".`);
48
+ else log(`The card could not be sent, so ${who} got the text with the link instead.`);
49
+ return 0;
50
+ } catch (e) {
51
+ error(`owner-setup: ${e.message}`);
52
+ return 1;
53
+ }
54
+ }
55
+
56
+ module.exports = { run, HELP };
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 };
package/bot.js CHANGED
@@ -264,6 +264,42 @@ setInterval(checkForUpdates, 5 * 60 * 1000);
264
264
 
265
265
  // ── Boot ────────────────────────────────────────────────────────────
266
266
 
267
+ // Tell a personal bot's owner in her Kazee DM that an app refused a pass: a
268
+ // one-tap card to switch the bot on there, else the text (core/owner-setup.js).
269
+ // Best effort: no Kazee adapter or DM means no message.
270
+ async function tellPersonalOwner(text, err) {
271
+ const ownerSetup = require("./core/owner-setup");
272
+ const kazee = adapters.find((a) => a.type === "kazee");
273
+ if (kazee) await ownerSetup.tellOwner(ownerSetup.forRefusal(err, text), { adapter: kazee });
274
+ }
275
+
276
+ // A week before the consent ends: the renew card, once a day.
277
+ async function maybeSendRenewNotice() {
278
+ const kazee = adapters.find((a) => a.type === "kazee");
279
+ if (kazee) await require("./core/owner-setup").maybeSendRenew({ adapter: kazee });
280
+ }
281
+
282
+ async function startBotProfile() {
283
+ const botProfile = require("./core/bot-profile");
284
+ const applyOwner = () => {
285
+ const owner = botProfile.applyOwner(config);
286
+ if (!owner) return;
287
+ for (const a of adapters) {
288
+ if (a.type === "kazee" || a.type === "spaces") a.ownerUserId = owner;
289
+ }
290
+ };
291
+ await botProfile.refresh();
292
+ applyOwner();
293
+ console.log(botProfile.isPersonal()
294
+ ? `Bot profile: personal, owner ${botProfile.ownerName() || botProfile.ownerUserId()}; apps ticked: ${botProfile.consentApplications().join(", ") || "none"}`
295
+ : `Bot profile: ${botProfile.get()?.bot?.botKind || "unknown (Central has no bot:me)"}`);
296
+ require("./core/app-pass").setOwnerNotifier(tellPersonalOwner);
297
+ botProfile.startRefreshing(applyOwner);
298
+ const renewTimer = setInterval(() => { maybeSendRenewNotice(); }, 60 * 60 * 1000);
299
+ if (renewTimer.unref) renewTimer.unref();
300
+ setTimeout(() => { maybeSendRenewNotice(); }, 60 * 1000).unref?.();
301
+ }
302
+
267
303
  // Minimal boot: adapters + slash commands only. No scheduler/crons, no dream,
268
304
  // no connected-apps, no loopback, no web UI, no onboarding — nothing that can
269
305
  // run an agent turn or write memory. The alert tells the owner exactly why and
@@ -332,6 +368,12 @@ async function safeModeBoot() {
332
368
  }
333
369
  } catch (e) {}
334
370
 
371
+ // Personal or autonomous, and who the one owner is: asked of Central before
372
+ // the adapters start (core/bot-profile.js). A personal bot's app calls go
373
+ // through passes for that owner (core/app-pass.js).
374
+ try { await startBotProfile(); }
375
+ catch (e) { console.error("bot profile init failed:", e.message); }
376
+
335
377
  initScheduler(adapters);
336
378
  try { require("./core/dream").initDream(adapters); }
337
379
  catch (e) { console.error("dream init failed:", e.message); }
@@ -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) {