@inetafrica/open-claudia 3.5.19 → 3.5.20

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,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 3.5.20
4
+
5
+ The Browser app in Kazee Chat, bot apps v1 (Sumeet, 9 Oct 12:02 EAT: "fine go ahead and push but also ensure you clear the images after they all done"). Kazee Chat opens it through Agent Space, which calls these pod routes with the control token.
6
+
7
+ - **A person can open the bot's browser from their direct chat.** `POST /api/apps/check` lets the Kazee owner or a chat on auth.json's authorized list open the `browser` app. After its slash commands, the Kazee adapter registers the app with chat-central at `AGENTSPACE_API_URL` + `/apps/browser`. A non-https or in-cluster address is skipped. `OC_BROWSER_APP=off` withdraws the app.
8
+ - **Take over and Hand back.** A driver lock (`browser-driver.json`, 0600) lasts 120 s from the last input, and a second person gets 409. Input events are checked field by field and relayed to the agent-browser stream. A hand-back wakes the bot once in that person's chat. While someone drives, the tool guard refuses the bot's own `agent-browser` commands, except reads.
9
+ - **Teach me.** It records clicks (with the element and a picture), typing per field, keys and scrolls. Characters typed into password or one-time-code fields are never kept. On Stop the bot is woken to post the steps, do one watched run, save the skill, and then run `open-claudia teach finish <id>`, which deletes the pictures and keeps the text. `teach cancel` deletes everything. A sweeper runs at boot and hourly: it deletes pictures after 24 h, stops abandoned recordings after 2 h, and removes recordings after 7 days.
10
+ - **Cards** pass `open: { app, label }` through. The system prompt tells bots that offer the app to send an Open Browser card when they need a person, and never to ask for passwords in chat.
11
+ - **Tests:** `test-browser-control.js`, `test-browser-teach.js`, `test-kazee-bot-apps.js` and `test-web-browser-app.js`, all in `npm test`.
12
+
3
13
  ## 3.5.19
4
14
 
5
15
  The memory duplicate-check fix as its own release (Sumeet, 8 Oct 13:08 EAT: "I'd rather just push out another release now rather than waiting for another day or so"). Everything in 3.5.18 below comes with it.
package/bin/cli.js CHANGED
@@ -452,6 +452,11 @@ switch (command) {
452
452
  require("./cards").runDecide(args.slice(1));
453
453
  break;
454
454
  }
455
+ case "teach": {
456
+ Promise.resolve(require("./teach").run(args.slice(1)))
457
+ .catch((e) => { console.error(`teach command failed: ${e.message}`); process.exitCode = 1; });
458
+ break;
459
+ }
455
460
  case "schedule-wakeup": {
456
461
  require("./schedule").runScheduleWakeup(args.slice(1));
457
462
  break;
@@ -623,6 +628,7 @@ Send tools (only work inside an active bot-spawned task):
623
628
  open-claudia card send <spec|-> Send a Kazee Card (decision, choice, rating, slider, report); answers wake this conversation
624
629
  open-claudia card update <id> ... Close/open a card you sent, add a note, or replace its spec (card example <kind> for specs)
625
630
  open-claudia decide "<q>" --option id="label" ... Queue a decision card for the owner (one at a time; decide list|cancel)
631
+ open-claudia teach list|show|finish|cancel <id> Browser recordings from Teach me; finish deletes the pictures, cancel the whole recording
626
632
 
627
633
  Memory tools:
628
634
  open-claudia transcript-search <query> Ranked FTS5 search over indexed transcripts
package/bin/teach.js ADDED
@@ -0,0 +1,78 @@
1
+ // `open-claudia teach ...`: the bot's side of Teach me recordings (core/browser-teach.js). The
2
+ // bot is woken with a recording, posts its steps, checks them with the person, saves the skill,
3
+ // and then deletes the pictures with `teach finish`.
4
+ //
5
+ // Whose recordings a call may use is decided by the RUNNING BOT (loopback whoami over the run's
6
+ // attestation), never by this process's env: an owner turn sees every recording, anyone else's
7
+ // turn only their own, and a process with no live run behind it none.
8
+
9
+ const { whoami } = require("./loopback-client");
10
+
11
+ const HELP = `Usage:
12
+ open-claudia teach list Browser recordings (Teach me) you may see, newest first
13
+ open-claudia teach show <id> The steps as text
14
+ open-claudia teach finish <id> Delete the pictures, keep the text steps (once the skill is saved)
15
+ open-claudia teach cancel <id> Delete the whole recording
16
+ An owner turn sees every recording; anyone else's turn sees only their own.`;
17
+
18
+ function fail(message, code = 1) {
19
+ console.error(message);
20
+ process.exitCode = code;
21
+ }
22
+
23
+ async function run(args, { caller = null } = {}) {
24
+ const teach = require("../core/browser-teach");
25
+ const sub = args[0];
26
+ if (!sub || sub === "help" || sub === "--help" || sub === "-h") {
27
+ console.log(HELP);
28
+ return;
29
+ }
30
+ if (!["list", "show", "finish", "cancel"].includes(sub)) return fail(HELP, 2);
31
+ let who = caller;
32
+ if (!who) {
33
+ try { who = await whoami(); } catch (_) { who = null; }
34
+ }
35
+ if (!who || who.attributed !== true) {
36
+ return fail("⛔ teach: this process is not attributed to a chat turn of this bot, so it cannot tell whose recordings it may use. Run it from the bot's turn.", 3);
37
+ }
38
+ const userId = who.owner === true ? null : String(who.userId || "");
39
+ if (userId === "") return fail("⛔ teach: this turn has no person to match recordings to.", 3);
40
+
41
+ if (sub === "list") {
42
+ const list = teach.listRecordings({ userId });
43
+ if (!list.length) {
44
+ console.log("No recordings.");
45
+ return;
46
+ }
47
+ for (const r of list) {
48
+ const pictures = r.picturesDeleted ? "pictures deleted" : "pictures kept";
49
+ console.log(`${r.id} ${String(r.status).padEnd(9)} ${String(r.steps).padStart(3)} steps ${pictures.padEnd(16)} ${r.startedAt} "${teach.cleanText(r.title, 80).replace(/"/g, "'")}"`);
50
+ }
51
+ return;
52
+ }
53
+
54
+ const id = args[1];
55
+ if (!teach.validId(id)) return fail(`teach ${sub}: give a recording id (open-claudia teach list)`, 2);
56
+ const rec = teach.getRecording(id, { userId });
57
+ if (!rec) return fail(`teach ${sub}: no recording ${id} that this turn may use`);
58
+
59
+ if (sub === "show") {
60
+ console.log(teach.formatRecording(rec));
61
+ return;
62
+ }
63
+ if (sub === "finish") {
64
+ const result = teach.finishRecording(id);
65
+ if (!result.ok) {
66
+ return fail(result.error === "still-recording"
67
+ ? `teach finish: ${id} is still recording; it can be finished once it is stopped`
68
+ : `teach finish: no recording ${id}`);
69
+ }
70
+ console.log(`Finished ${id}: ${result.removed} picture${result.removed === 1 ? "" : "s"} deleted, ${result.recording.steps.length} text steps kept.`);
71
+ return;
72
+ }
73
+ const result = teach.cancelRecording(id);
74
+ if (!result.ok) return fail(`teach cancel: no recording ${id}`);
75
+ console.log(`Cancelled ${id}: the recording and its pictures are deleted.`);
76
+ }
77
+
78
+ module.exports = { run, HELP };
package/bot.js CHANGED
@@ -275,6 +275,8 @@ async function safeModeBoot() {
275
275
  console.error(`Adapter ${adapter.id} failed to start in safe mode:`, e.message);
276
276
  }
277
277
  }
278
+ // Teach-me pictures are still deleted on schedule: it only removes files, never runs a turn.
279
+ try { require("./core/browser-teach").startRetentionSweeper(); } catch (e) { console.error("Teach-me retention sweeper start failed:", e.message); }
278
280
  const alert = safeBoot.formatSafeModeAlert({ version: CURRENT_VERSION });
279
281
  console.error(alert);
280
282
  const tg = adapters.find((a) => a.type === "telegram");
@@ -391,6 +393,11 @@ async function safeModeBoot() {
391
393
  // Kazee Cards: snoozed owner decisions are asked again when due.
392
394
  try { require("./core/cards").startDecisionPump(registry); } catch (e) { console.error("Decision pump start failed:", e.message); }
393
395
 
396
+ // Browser app: a driver whose panel went quiet hands the browser back (30 s sweep), and
397
+ // Teach-me pictures and old recordings are deleted on schedule (boot + hourly).
398
+ try { require("./core/browser-control").startSweeper(); } catch (e) { console.error("Browser lock sweeper start failed:", e.message); }
399
+ try { require("./core/browser-teach").startRetentionSweeper(); } catch (e) { console.error("Teach-me retention sweeper start failed:", e.message); }
400
+
394
401
  // This incarnation counts as healthy once it survives the stable window —
395
402
  // resets the crash-loop streak the safe-mode gate counts at boot.
396
403
  setTimeout(() => { try { safeBoot.markBootStable(); } catch (e) {} }, safeBoot.STABLE_UPTIME_MS).unref();
@@ -1750,6 +1750,36 @@ class KazeeAdapter {
1750
1750
  } catch (e) {
1751
1751
  console.error("Kazee registerCommands failed:", e.message);
1752
1752
  }
1753
+ await this.registerApps();
1754
+ }
1755
+
1756
+ // Bot apps (chat-central PUT /bot/:botId/apps, the same shape as commands): with Agent Space
1757
+ // configured, the Browser app, opened from the Apps button of a direct chat with this bot or a
1758
+ // card's Open button (core/bot-apps.js). OC_BROWSER_APP=off sends an empty list. An
1759
+ // AGENTSPACE_API_URL that is not a public https address is skipped with one log line (chat-central
1760
+ // would refuse it). An older chat-central answers 404; that, or any failure, is logged once and
1761
+ // otherwise ignored.
1762
+ async registerApps(env = process.env) {
1763
+ const { apps, skip } = require("../../core/bot-apps").registrationPlan(env);
1764
+ if (skip && !this._appsSkipLogged) {
1765
+ this._appsSkipLogged = true;
1766
+ console.error(`Kazee registerApps skipped: ${skip}. The Browser app stays hidden.`);
1767
+ }
1768
+ if (!apps || !this.botUserId) return null;
1769
+ try {
1770
+ const res = await this._request("PUT", `/bot/${this.botUserId}/apps`, { apps });
1771
+ if (apps.length) console.log(`Kazee bot apps registered: ${apps.map((a) => a.key).join(", ")}`);
1772
+ return res;
1773
+ } catch (e) {
1774
+ if (!this._appsFailureLogged) {
1775
+ this._appsFailureLogged = true;
1776
+ const old = / → 404:/.test(String(e.message || ""));
1777
+ console.error(old
1778
+ ? "Kazee registerApps: this chat-central has no bot apps yet (404); the Browser app stays hidden."
1779
+ : `Kazee registerApps failed (the Browser app stays hidden): ${String(e.message || e).slice(0, 300)}`);
1780
+ }
1781
+ return null;
1782
+ }
1753
1783
  }
1754
1784
 
1755
1785
  // ── HTTP helpers ────────────────────────────────────────────────
@@ -0,0 +1,133 @@
1
+ // Bot apps in Kazee Chat (SPEC "Bot apps v1", contracts C1 and C8).
2
+ //
3
+ // The bot registers the apps it offers with chat-central (PUT /bot/:botId/apps, done by the
4
+ // Kazee adapter after its slash commands), and Kazee Chat shows them behind an Apps button in
5
+ // a direct chat with the bot. Opening one goes through Agent Space (the front door), which asks
6
+ // this bot, over the control token, whether that person may use it: checkAccess() below.
7
+ //
8
+ // v1 has one app, "browser": the bot's own agent-browser, shown live, with Take over and
9
+ // Teach me (core/browser-control.js, core/browser-teach.js). OC_BROWSER_APP=off withdraws it:
10
+ // the next registration sends an empty list and every check answers "not allowed".
11
+
12
+ const BROWSER_APP_KEY = "browser";
13
+ const ID_RE = /^[A-Za-z0-9._:-]{1,100}$/;
14
+
15
+ function browserAppEnabled(env = process.env) {
16
+ return String(env.OC_BROWSER_APP || "").trim().toLowerCase() !== "off";
17
+ }
18
+
19
+ // An address only reachable inside the cluster or this machine (a Service name such as
20
+ // agentspace-backend.<ns>.svc, localhost, an IP): no person's Kazee Chat can open a page there.
21
+ function internalHost(hostname) {
22
+ const host = String(hostname || "").toLowerCase().replace(/\.$/, "");
23
+ if (!host.includes(".") || host.startsWith("[")) return true; // a bare Service name, or IPv6
24
+ if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host)) return true;
25
+ return /(^|\.)(localhost|local|internal|svc|cluster\.local)$/.test(host);
26
+ }
27
+
28
+ // Why AGENTSPACE_API_URL cannot carry the page, or "" when it can. chat-central accepts only
29
+ // https addresses on its allowed origins, so anything else would be refused there anyway.
30
+ function browserAppUrlProblem(apiUrl) {
31
+ let u;
32
+ try { u = new URL(String(apiUrl || "").trim()); } catch (_) { return "it is not a URL"; }
33
+ if (u.protocol !== "https:") return "it is not https";
34
+ if (internalHost(u.hostname)) return "it is an in-cluster address";
35
+ return "";
36
+ }
37
+
38
+ // The page lives on Agent Space's API host: <AGENTSPACE_API_URL without trailing slash> +
39
+ // "/apps/browser". Every live pod has AGENTSPACE_API_URL=https://api.agentspace.kazee.africa/api,
40
+ // so the page is https://api.agentspace.kazee.africa/api/apps/browser. A bare origin means
41
+ // "/api", the same path base the pod's other Agent Space calls use (core/cluster-client.js).
42
+ function browserAppUrl(apiUrl) {
43
+ if (browserAppUrlProblem(apiUrl)) return null;
44
+ const u = new URL(String(apiUrl).trim());
45
+ const base = u.pathname.replace(/\/+$/, "") || "/api";
46
+ return `${u.origin}${base}/apps/browser`;
47
+ }
48
+
49
+ // What to do with chat-central, and why not:
50
+ // { apps: null } nothing (no Agent Space, so no page to open)
51
+ // { apps: null, skip: <why> } nothing: AGENTSPACE_API_URL is not a public https address
52
+ // { apps: [] } withdraw (OC_BROWSER_APP=off)
53
+ // { apps: [<browser>] } offer the Browser app
54
+ function registrationPlan(env = process.env) {
55
+ const raw = String(env.AGENTSPACE_API_URL || "").trim();
56
+ if (!raw) return { apps: null, skip: null };
57
+ if (!browserAppEnabled(env)) return { apps: [], skip: null };
58
+ const problem = browserAppUrlProblem(raw);
59
+ if (problem) {
60
+ let origin = "";
61
+ try { origin = new URL(raw).origin; } catch (_) { origin = ""; }
62
+ const shown = origin && origin !== "null" ? ` (${origin})` : "";
63
+ return { apps: null, skip: `AGENTSPACE_API_URL${shown} cannot carry the Browser page: ${problem}, and chat-central only accepts public https app addresses` };
64
+ }
65
+ return { apps: [{ key: BROWSER_APP_KEY, title: "Browser", url: browserAppUrl(raw), icon: "globe" }], skip: null };
66
+ }
67
+
68
+ // What to register with chat-central: null = do nothing, [] = withdraw, else the Browser app.
69
+ function appsToRegister(env = process.env) {
70
+ return registrationPlan(env).apps;
71
+ }
72
+
73
+ function ownerUserId() {
74
+ try {
75
+ const value = require("./config").config.KAZEE_OWNER_USER_ID;
76
+ if (value) return String(value);
77
+ } catch (_) { /* fall back to the process env */ }
78
+ return String(process.env.KAZEE_OWNER_USER_ID || "");
79
+ }
80
+
81
+ function ownerName() {
82
+ try {
83
+ const owner = (require("./people").owners() || [])[0];
84
+ return owner && owner.name ? String(owner.name) : "";
85
+ } catch (_) {
86
+ return "";
87
+ }
88
+ }
89
+
90
+ // The authorized list of auth.json, read under its store lock so a roster change in flight is
91
+ // never half seen (core/access.js mutateAuth holds the same lock).
92
+ function authorizedEntries() {
93
+ const { loadAuth } = require("./access");
94
+ const { withFileLock } = require("./fsutil");
95
+ const { AUTH_FILE } = require("./config");
96
+ return withFileLock(AUTH_FILE, () => loadAuth().authorized);
97
+ }
98
+
99
+ // C1: may this person open this app in this direct chat? Agent Space has already checked with
100
+ // chat-central that `userId` is the person in the direct chat `chatId` with this bot, so:
101
+ // - the Kazee owner (KAZEE_OWNER_USER_ID) is allowed, role "owner";
102
+ // - a chat in auth.json's authorized list is allowed, role "user" ("owner" when that entry is
103
+ // an owner entry), with the name the roster has for it;
104
+ // - anyone else, and any app other than "browser", is not.
105
+ function checkAccess({ app, chatId, userId } = {}) {
106
+ const no = { allowed: false, role: null, name: null };
107
+ if (app !== BROWSER_APP_KEY || !browserAppEnabled()) return no;
108
+ const chat = String(chatId || "");
109
+ const user = String(userId || "");
110
+ if (!ID_RE.test(chat) || !ID_RE.test(user)) return no;
111
+ let entries = [];
112
+ try { entries = authorizedEntries(); } catch (_) { entries = []; }
113
+ const entry = entries.find((a) => a && String(a.chatId) === chat) || null;
114
+ const owner = ownerUserId();
115
+ if (owner && user === owner) {
116
+ return { allowed: true, role: "owner", name: String((entry && entry.name) || ownerName() || "") };
117
+ }
118
+ if (entry) {
119
+ return { allowed: true, role: entry.isOwner === true ? "owner" : "user", name: String(entry.name || "") };
120
+ }
121
+ return no;
122
+ }
123
+
124
+ module.exports = {
125
+ BROWSER_APP_KEY,
126
+ ID_RE,
127
+ appsToRegister,
128
+ browserAppEnabled,
129
+ browserAppUrl,
130
+ browserAppUrlProblem,
131
+ checkAccess,
132
+ registrationPlan,
133
+ };