@inetafrica/open-claudia 3.5.74 → 3.5.75

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.
@@ -0,0 +1,228 @@
1
+ // What kind of bot this is, from Central (plan v2 "personal bots act for their
2
+ // owner", 25 Oct).
3
+ //
4
+ // On start, and every 15 minutes, the bot asks Central with its own key
5
+ // (GET /api/bots/me): personal or autonomous, its one owner, and the owner's
6
+ // consent (the apps she ticked, the end date, renewDue a week before it ends).
7
+ // The answer is kept on disk so a restart while Central is down still knows.
8
+ //
9
+ // A personal bot:
10
+ // • has exactly one owner, the person in Central. applyOwner() puts her id in
11
+ // KAZEE_OWNER_USER_ID, and access.js counts no other owner (no second-owner
12
+ // records). Admins look after the bot through Agent Space and Central.
13
+ // • never hands its own key to an app: core/app-pass.js trades it for a pass.
14
+ // • gets its own instructions (instructionsBlock).
15
+ // An autonomous bot, or a bot whose Central has no bot:me yet, is unchanged.
16
+
17
+ const fs = require("fs");
18
+ const path = require("path");
19
+ const https = require("https");
20
+ const http = require("http");
21
+ const { URL } = require("url");
22
+
23
+ const FILE_NAME = "bot-profile.json";
24
+ const REFRESH_MS = 15 * 60 * 1000;
25
+
26
+ let profile;
27
+ let timer = null;
28
+
29
+ function configModule() { return require("./config"); }
30
+
31
+ function profileFile(dir) {
32
+ return path.join(dir || configModule().CONFIG_DIR, FILE_NAME);
33
+ }
34
+
35
+ function readProfile(dir) {
36
+ try {
37
+ const raw = JSON.parse(fs.readFileSync(profileFile(dir), "utf-8"));
38
+ return raw && raw.bot ? raw : null;
39
+ } catch (_) {
40
+ return null;
41
+ }
42
+ }
43
+
44
+ function get() {
45
+ if (profile === undefined) profile = readProfile();
46
+ return profile;
47
+ }
48
+
49
+ function isPersonal() { return get()?.bot?.botKind === "personal"; }
50
+ function ownerUserId() { return isPersonal() ? String(get()?.owner?.id || "") : ""; }
51
+ function ownerName() { return isPersonal() ? String(get()?.owner?.name || "") : ""; }
52
+ function ownerFirstName() { return ownerName().split(" ")[0] || "your owner"; }
53
+ function consent() { return isPersonal() ? get()?.consent || null : null; }
54
+ function consentApplications() {
55
+ const c = consent();
56
+ return c && c.live !== false ? (c.applications || []).map((a) => a.name).filter(Boolean) : [];
57
+ }
58
+ function renewDue() { return !!consent()?.renewDue; }
59
+
60
+ // The apps whose back ends take a pass, so the owner can tick them (Central's
61
+ // OWNER_PASS_APPLICATIONS). Central's list when bot:me sends one, else its
62
+ // default; Chat is left out, the bot talks there as itself.
63
+ const DEFAULT_PASS_APPLICATIONS = ["Ticket_Central", "kazee-crm", "agent-space-backend", "kazee-infrastructure", "kazee-spaces-backend"];
64
+ function passApplications() {
65
+ if (!isPersonal()) return [];
66
+ const p = get();
67
+ const list = Array.isArray(p?.passApplications) && p.passApplications.length ? p.passApplications : DEFAULT_PASS_APPLICATIONS;
68
+ const chat = p?.chatApplications || [];
69
+ return list.filter((a) => a && !chat.includes(a));
70
+ }
71
+
72
+ // Read straight from disk with no config: config.js calls this while it loads,
73
+ // so the owner is right before any adapter is built.
74
+ function cachedPersonalOwnerId(dir) {
75
+ const p = readProfile(dir);
76
+ return p?.bot?.botKind === "personal" && p.owner?.id ? String(p.owner.id) : "";
77
+ }
78
+
79
+ // A personal bot's one owner replaces the configured Kazee owner.
80
+ function applyOwner(config) {
81
+ const owner = ownerUserId();
82
+ if (owner && config) config.KAZEE_OWNER_USER_ID = owner;
83
+ return owner;
84
+ }
85
+
86
+ function centralUrl() {
87
+ const { config } = configModule();
88
+ return String(config.CENTRAL_URL || "https://central.inet.africa").replace(/\/$/, "");
89
+ }
90
+
91
+ function botToken() {
92
+ const { config } = configModule();
93
+ return config.KAZEE_BOT_TOKEN || process.env.KAZEE_BOT_TOKEN || "";
94
+ }
95
+
96
+ // One JSON request to Central. Resolves { status, body } for any HTTP answer;
97
+ // rejects only when Central can't be reached.
98
+ function centralRequest(method, urlPath, token, body, timeoutMs = 15000) {
99
+ return new Promise((resolve, reject) => {
100
+ const u = new URL(centralUrl() + urlPath);
101
+ const lib = u.protocol === "https:" ? https : http;
102
+ const data = body != null ? Buffer.from(JSON.stringify(body)) : null;
103
+ const headers = { Authorization: `Bearer ${token}`, Accept: "application/json" };
104
+ if (data) {
105
+ headers["Content-Type"] = "application/json";
106
+ headers["Content-Length"] = data.length;
107
+ }
108
+ const req = lib.request(
109
+ { method, hostname: u.hostname, port: u.port || (u.protocol === "https:" ? 443 : 80), path: u.pathname + (u.search || ""), headers },
110
+ (res) => {
111
+ let chunks = "";
112
+ res.on("data", (c) => { chunks += c; });
113
+ res.on("end", () => {
114
+ let parsed = {};
115
+ try { parsed = chunks ? JSON.parse(chunks) : {}; } catch (_) {}
116
+ resolve({ status: res.statusCode, body: parsed });
117
+ });
118
+ },
119
+ );
120
+ req.on("error", reject);
121
+ req.setTimeout(timeoutMs, () => req.destroy(new Error(`${method} ${urlPath} timeout`)));
122
+ if (data) req.write(data);
123
+ req.end();
124
+ });
125
+ }
126
+
127
+ // Ask Central what this bot is. An old Central (404) or a refusal leaves a
128
+ // bot that was never told as it was; Central down keeps the last answer.
129
+ async function refresh({ request = centralRequest } = {}) {
130
+ const token = botToken();
131
+ if (!token) return get();
132
+ let res;
133
+ try {
134
+ res = await request("GET", "/api/bots/me", token);
135
+ } catch (e) {
136
+ console.error("bot-profile: Central unreachable, keeping the last answer:", e.message);
137
+ return get();
138
+ }
139
+ if (res && res.status === 200 && res.body && res.body.bot) {
140
+ profile = {
141
+ bot: res.body.bot,
142
+ owner: res.body.owner || null,
143
+ consent: res.body.consent || null,
144
+ chatApplications: res.body.chatApplications || [],
145
+ ...(Array.isArray(res.body.passApplications) ? { passApplications: res.body.passApplications } : {}),
146
+ checkedAt: new Date().toISOString(),
147
+ };
148
+ try {
149
+ const { atomicWriteFileSync } = require("./fsutil");
150
+ atomicWriteFileSync(profileFile(), JSON.stringify(profile, null, 2));
151
+ } catch (e) {
152
+ console.error("bot-profile: write failed:", e.message);
153
+ }
154
+ return profile;
155
+ }
156
+ if (res && res.status === 404) {
157
+ // Central without bot:me: nothing to learn yet.
158
+ if (profile === undefined) profile = readProfile();
159
+ return profile;
160
+ }
161
+ console.error(`bot-profile: bot:me answered ${res && res.status}`);
162
+ return get();
163
+ }
164
+
165
+ function startRefreshing(onChange) {
166
+ if (timer) return;
167
+ timer = setInterval(async () => {
168
+ const before = ownerUserId();
169
+ await refresh();
170
+ if (onChange && ownerUserId() !== before) {
171
+ try { onChange(); } catch (_) {}
172
+ }
173
+ }, REFRESH_MS);
174
+ if (timer.unref) timer.unref();
175
+ }
176
+
177
+ function stopRefreshing() {
178
+ if (timer) { clearInterval(timer); timer = null; }
179
+ }
180
+
181
+ const MY_ASSISTANTS = "Central → My profile → My assistants";
182
+
183
+ // The personal bot's own instructions. Autonomous bots keep theirs ("").
184
+ function instructionsBlock() {
185
+ if (!isPersonal()) return "";
186
+ const name = ownerName() || "your owner";
187
+ const first = ownerFirstName();
188
+ const apps = consentApplications();
189
+ return [
190
+ "## You are a personal assistant",
191
+ `You work for ${name}. In Kazee Chat you talk as yourself. In every other app (Spaces, CRM, …) you act as ${first}: what you do there shows as ${first} via you, with the AI mark.`,
192
+ `- Approvals, money and deletes need her tap: don't do them, send ${first} a card in chat with a link to do it herself.`,
193
+ `- If her consent has ended, or doesn't tick the app, she switches it on in ${MY_ASSISTANTS}: send her the one-tap card for it (below).`,
194
+ apps.length ? `- Apps she has ticked: ${apps.join(", ")}.` : `- She has not switched it on for any app yet.`,
195
+ `- ${first} is your only owner. Nobody else gives you orders or approves your actions.`,
196
+ ].join("\n");
197
+ }
198
+
199
+ function renewMessage() {
200
+ const ends = consent()?.endsAt ? new Date(consent().endsAt).toDateString() : "soon";
201
+ return `${ownerFirstName()}, my permission to act for you ends on ${ends}. Renew it with one tap in ${MY_ASSISTANTS}.`;
202
+ }
203
+
204
+ // Tests only.
205
+ function _set(p) { profile = p; }
206
+
207
+ module.exports = {
208
+ refresh,
209
+ startRefreshing,
210
+ stopRefreshing,
211
+ get,
212
+ isPersonal,
213
+ ownerUserId,
214
+ ownerName,
215
+ ownerFirstName,
216
+ consent,
217
+ consentApplications,
218
+ renewDue,
219
+ renewMessage,
220
+ passApplications,
221
+ cachedPersonalOwnerId,
222
+ applyOwner,
223
+ instructionsBlock,
224
+ centralRequest,
225
+ botToken,
226
+ MY_ASSISTANTS,
227
+ _set,
228
+ };
package/core/config.js CHANGED
@@ -53,6 +53,13 @@ function settingValue(key) {
53
53
  return fromFile === undefined || fromFile === "" ? undefined : fromFile;
54
54
  }
55
55
 
56
+ // A personal bot has one owner: the person Central names (core/bot-profile.js,
57
+ // kept on disk from the last bot:me). Applied here, before any adapter is built.
58
+ try {
59
+ const centralOwner = require("./bot-profile").cachedPersonalOwnerId(CONFIG_DIR);
60
+ if (centralOwner) config.KAZEE_OWNER_USER_ID = centralOwner;
61
+ } catch (_) { /* no profile yet */ }
62
+
56
63
  function hasPathSeparator(value) {
57
64
  return value.includes("/") || value.includes("\\");
58
65
  }
@@ -96,14 +96,28 @@ function getJson(baseUrl, urlPath, token) {
96
96
  }
97
97
 
98
98
  // Ask central which apps this bot token is a member of. Returns [] on failure
99
- // (caller keeps the last-known list).
100
- async function fetchConnectedApps() {
99
+ // (caller keeps the last-known list). A personal bot watches the apps its owner
100
+ // ticked in her consent (core/bot-profile.js), as her: its own key only works
101
+ // in Kazee Chat once its key limit is on.
102
+ async function fetchConnectedApps({ request } = {}) {
103
+ const botProfile = require("./bot-profile");
104
+ if (botProfile.isPersonal()) {
105
+ return botProfile.consentApplications().map((name) => ({ id: name, name }));
106
+ }
101
107
  const token = botToken();
102
108
  if (!token) return [];
103
- const res = await getJson(CENTRAL_URL, "/api/principal/applications", token);
109
+ const res = request
110
+ ? (await request("GET", "/api/principal/applications", token)).body
111
+ : await getJson(CENTRAL_URL, "/api/principal/applications", token);
104
112
  return Array.isArray(res?.applications) ? res.applications : [];
105
113
  }
106
114
 
115
+ // The token to poll an app with: a pass for a personal bot (core/app-pass.js),
116
+ // the bot's own key otherwise.
117
+ async function tokenForApp(app, opts) {
118
+ return require("./app-pass").tokenFor(String(app?.name || ""), opts);
119
+ }
120
+
107
121
  function registerConnector(connector) {
108
122
  if (!connector || !connector.appKey) throw new Error("connector needs an appKey");
109
123
  connectors.set(connector.appKey, connector);
@@ -141,7 +155,7 @@ async function pollApp(app) {
141
155
  const connector = connectorFor(app);
142
156
  if (!connector) { state.status = "no-connector"; return; }
143
157
  try {
144
- const items = await connector.poll(app, botToken());
158
+ const items = await connector.poll(app, await tokenForApp(app));
145
159
  const added = recordItems(state, items || []);
146
160
  state.lastPollAt = now;
147
161
  state.status = "ok";
@@ -244,6 +258,7 @@ module.exports = {
244
258
  stopConnectedApps,
245
259
  registerConnector,
246
260
  fetchConnectedApps,
261
+ tokenForApp,
247
262
  snapshot,
248
263
  getAllInboxes,
249
264
  STORE_FILE,
package/core/handlers.js CHANGED
@@ -642,7 +642,9 @@ register({
642
642
  handler: async (env) => {
643
643
  if (!ownerEnv(env)) return;
644
644
  const { mint, TTL_MS } = require("./web-otp");
645
- const { token } = mint();
645
+ // The link names who asked, so a personal bot's dashboard knows it is
646
+ // her (core/access.js); an autonomous bot ignores it.
647
+ const { token } = mint({ userId: env.userId || "" });
646
648
  const url = `${dashboardBaseUrl()}/otp/${token}`;
647
649
  const mins = Math.round(TTL_MS / 60000);
648
650
  const lines = [`Web dashboard login (valid ${mins} min, single use):`, url];
package/core/identity.js CHANGED
@@ -98,6 +98,20 @@ function isConfiguredOwnerChannel(transport, channelId) {
98
98
  // is the same configured Kazee owner id — recognise them on Spaces too, else
99
99
  // the owner's own comments are classed external (default-deny).
100
100
  if (t === "spaces") return !!cfg.config.KAZEE_OWNER_USER_ID && c === String(cfg.config.KAZEE_OWNER_USER_ID);
101
+ // A personal bot's dashboard and voice bridge are owner channels only when
102
+ // they name its one owner (Central's); see access.transportOwnerUserId.
103
+ if (t === "voice" || t === "web") {
104
+ let personal = false, owner = "";
105
+ try {
106
+ const botProfile = require("./bot-profile");
107
+ personal = botProfile.isPersonal();
108
+ owner = botProfile.ownerUserId();
109
+ } catch (e) {}
110
+ if (personal) {
111
+ if (!owner || c !== owner) return false;
112
+ return t === "web" || String(cfg.config.VOICE_OWNER_USER_ID || "") === owner;
113
+ }
114
+ }
101
115
  if (t === "voice") return c === String(cfg.config.VOICE_OWNER_USER_ID || "voice-owner");
102
116
  // Web dashboard chat: fixed single-owner channel gated by the dashboard
103
117
  // session, so its identity is operator-implied rather than env-declared.
package/core/loopback.js CHANGED
@@ -108,6 +108,7 @@ const JSON_KINDS = new Set([
108
108
  "tool-gate",
109
109
  "say", "call-say", "call", "tts", "transcribe",
110
110
  "card-send", "card-update", "card-show", "decide-add", "decide-list", "decide-cancel",
111
+ "owner-setup",
111
112
  ]);
112
113
 
113
114
  // Who is calling, decided by the bot: the principal of a LIVE run whose
@@ -452,6 +453,26 @@ async function handleJson(req, res, url, kind) {
452
453
  return handleCardKind(res, kind, payload, { channelId, adapterId, adapter });
453
454
  }
454
455
 
456
+ // `open-claudia owner-setup` (core/owner-setup.js): the one-tap card that
457
+ // lets a personal bot's owner switch it on or renew it. It always goes to
458
+ // HER direct chat, never this room; the bot decides what it says from its
459
+ // own profile. A run that is not hers sends each card at most once a day.
460
+ if (kind === "owner-setup") {
461
+ const caller = callerOf(payload);
462
+ if (!caller.attributed) return reply(res, 403, { error: "owner-setup can only be used from a live, attributed run" });
463
+ const ownerSetup = require("./owner-setup");
464
+ const planned = ownerSetup.plan({ apps: Array.isArray(payload.apps) ? payload.apps.map(String) : [], renew: payload.renew === true });
465
+ if (planned.refused) return reply(res, 400, { error: planned.refused });
466
+ if (planned.nothing) return reply(res, 200, { ok: true, sent: null, nothing: planned.nothing });
467
+ try {
468
+ const sent = await ownerSetup.tellOwnerAsked(planned, { owner: caller.owner === true });
469
+ if (!sent) return reply(res, 502, { error: "could not reach the owner's direct chat in Kazee" });
470
+ return reply(res, 200, { ok: true, sent, title: planned.card ? planned.card.title : null });
471
+ } catch (e) {
472
+ return reply(res, 502, { error: e.message });
473
+ }
474
+ }
475
+
455
476
  if (kind === "call-say") {
456
477
  try {
457
478
  const result = await require("./call-speech").speak({ ...payload, channelId, userId, adapter: adapterId });
@@ -0,0 +1,216 @@
1
+ // The owner switches a personal bot on (or renews it) with one tap (SEC-28,
2
+ // Sumeet 30 Oct 2026).
3
+ //
4
+ // Instead of telling her where the page is, the bot sends its owner a Kazee
5
+ // Chat card with one link button that opens Central straight at the form; she
6
+ // only taps Confirm or Renew. Used when an app refuses a pass because she has
7
+ // not switched the bot on there (core/app-pass.js), for the daily renew notice
8
+ // a week before the consent ends, and for `open-claudia owner-setup`.
9
+ //
10
+ // The card is a "report" card (no questions) with a `link` block, at most 2 per
11
+ // card (chat-central, 30 Oct). If the card can't be sent the owner gets the old
12
+ // text, with the link.
13
+ //
14
+ // The bot can never switch itself on: the link only opens the form, and only
15
+ // the owner, signed in to Central, can confirm it.
16
+
17
+ const botProfile = require("./bot-profile");
18
+ const appPass = require("./app-pass");
19
+
20
+ const CENTRAL_WEB = "https://central.kazee.africa";
21
+ const ASSISTANTS_PATH = "/settings/assistants";
22
+ const LINK_LABEL_MAX = 40;
23
+ const CONSENT_DAYS = 90;
24
+ const SEND_EVERY_MS = 24 * 60 * 60 * 1000;
25
+
26
+ // The refusals the owner fixes herself by switching the bot on for the app.
27
+ const SWITCH_ON_REASONS = new Set(["consent-none", "consent-off", "consent-ended", "app-not-ticked"]);
28
+
29
+ const sentAt = new Map(); // card key -> last sent at (non-owner callers only)
30
+ let lastRenewDay = "";
31
+
32
+ function switchOnUrl(botId, apps) {
33
+ const list = apps.map((a) => encodeURIComponent(a)).join(",");
34
+ return `${CENTRAL_WEB}${ASSISTANTS_PATH}?bot=${encodeURIComponent(botId)}&do=switch-on&apps=${list}`;
35
+ }
36
+
37
+ function renewUrl(botId) {
38
+ return `${CENTRAL_WEB}${ASSISTANTS_PATH}?bot=${encodeURIComponent(botId)}&do=renew`;
39
+ }
40
+
41
+ // "Tickets", "Tickets and CRM", "Tickets, CRM and Spaces".
42
+ function listWords(words) {
43
+ if (words.length <= 1) return words.join("");
44
+ return `${words.slice(0, -1).join(", ")} and ${words[words.length - 1]}`;
45
+ }
46
+
47
+ function dateWords(when) {
48
+ const d = new Date(when);
49
+ if (!Number.isFinite(d.getTime())) return "soon";
50
+ return d.toLocaleDateString("en-GB", { weekday: "long", day: "numeric", month: "long", year: "numeric" });
51
+ }
52
+
53
+ // The bot as the owner knows it: its own name (BOT_NAME, else its hostname),
54
+ // else Central's name for it; and Central's id.
55
+ function botForCard() {
56
+ let name = "";
57
+ try { name = String(require("./config").settingValue("BOT_NAME") || "").trim(); } catch (_) {}
58
+ // Else the pod's name from its public hostname (as the dashboard does).
59
+ if (!name) {
60
+ try {
61
+ const url = String(require("./config").settingValue("WEB_PUBLIC_URL") || "").trim();
62
+ if (url) name = new URL(url).hostname.split(".")[0] || "";
63
+ } catch (_) {}
64
+ }
65
+ if (!name) name = botProfile.get()?.bot?.name || "your assistant";
66
+ return { id: String(botProfile.get()?.bot?.id || ""), name: name.charAt(0).toUpperCase() + name.slice(1) };
67
+ }
68
+
69
+ function linkBlock(label, url) {
70
+ return { type: "link", label: String(label).slice(0, LINK_LABEL_MAX), url };
71
+ }
72
+
73
+ function buildSwitchOnCard({ bot, apps }) {
74
+ const labels = apps.map(appPass.appLabel);
75
+ const where = labels.length <= 2 ? listWords(labels) : "your apps";
76
+ return {
77
+ v: 1,
78
+ kind: "report",
79
+ title: `Let ${bot.name} work in ${where} for you`,
80
+ body: `${bot.name} can then work in ${listWords(labels)} as you, with the AI mark, for ${CONSENT_DAYS} days, and you can switch it off any time.`,
81
+ blocks: [linkBlock("Switch me on", switchOnUrl(bot.id, apps))],
82
+ };
83
+ }
84
+
85
+ function buildRenewCard({ bot, endsAt }) {
86
+ return {
87
+ v: 1,
88
+ kind: "report",
89
+ title: `Keep ${bot.name} working for you`,
90
+ body: `${bot.name}'s permission to act for you ends on ${dateWords(endsAt)}; renew it for another ${CONSENT_DAYS} days.`,
91
+ blocks: [linkBlock("Renew", renewUrl(bot.id))],
92
+ };
93
+ }
94
+
95
+ // The text an old app (or a failed card) shows: the words plus the link.
96
+ function withLink(text, card) {
97
+ const url = card?.blocks?.find((b) => b.type === "link")?.url;
98
+ return url ? `${text}\n${url}` : text;
99
+ }
100
+
101
+ function switchOnText(apps) {
102
+ const words = listWords(apps.map(appPass.appLabel));
103
+ return `${botProfile.ownerFirstName()}, to let me work in ${words} for you: open ${botProfile.MY_ASSISTANTS}, tap Let it act for me and tick ${words}.`;
104
+ }
105
+
106
+ // A refused pass: the switch-on card for that app, or (another reason, like
107
+ // the owner's own account lacking access) just the text.
108
+ function forRefusal(err, text) {
109
+ const message = text || err?.ownerMessage || "";
110
+ if (!err || !SWITCH_ON_REASONS.has(err.reason) || !botProfile.isPersonal()) return { text: message };
111
+ const card = buildSwitchOnCard({ bot: botForCard(), apps: [err.applicationName] });
112
+ return { card, text: withLink(message, card), key: `switch-on:${err.applicationName}` };
113
+ }
114
+
115
+ function forRenew() {
116
+ const card = buildRenewCard({ bot: botForCard(), endsAt: botProfile.consent()?.endsAt });
117
+ return { card, text: withLink(botProfile.renewMessage(), card), key: "renew" };
118
+ }
119
+
120
+ // What `open-claudia owner-setup` sends. `apps` names Central apps (or the
121
+ // labels the owner uses: "Tickets"); none means the pass-ready apps the live
122
+ // consent doesn't tick yet. Answers { card, text } or { nothing: "<line>" }.
123
+ function plan({ apps = [], renew = false } = {}) {
124
+ if (!botProfile.isPersonal()) {
125
+ return { refused: "Only a personal bot is switched on by its owner; this bot works with its own key." };
126
+ }
127
+ const consent = botProfile.consent();
128
+ if (renew && consent?.endsAt) return forRenew();
129
+ const wanted = apps.map(appName).filter(Boolean);
130
+ const on = botProfile.consentApplications();
131
+ const missing = wanted.length ? wanted : botProfile.passApplications().filter((a) => !on.includes(a));
132
+ if (missing.length) {
133
+ const card = buildSwitchOnCard({ bot: botForCard(), apps: missing });
134
+ return { card, text: withLink(switchOnText(missing), card), key: `switch-on:${missing.join(",")}` };
135
+ }
136
+ if (botProfile.renewDue()) return forRenew();
137
+ const until = consent?.endsAt ? `, until ${dateWords(consent.endsAt)}` : "";
138
+ return { nothing: `Already switched on for every app I can work in for ${botProfile.ownerFirstName()} (${listWords(on.map(appPass.appLabel))})${until}. Nothing sent.` };
139
+ }
140
+
141
+ // "Tickets" -> "Ticket_Central"; a Central name stays as it is.
142
+ function appName(raw) {
143
+ const s = String(raw || "").trim();
144
+ if (!s) return "";
145
+ const known = [...new Set([...botProfile.passApplications(), ...botProfile.consentApplications()])];
146
+ return known.find((n) => n === s)
147
+ || known.find((n) => appPass.appLabel(n).toLowerCase() === s.toLowerCase())
148
+ || s;
149
+ }
150
+
151
+ function kazeeAdapter() {
152
+ try { return require("./adapter-registry").getAdapters().find((a) => a.type === "kazee") || null; }
153
+ catch (_) { return null; }
154
+ }
155
+
156
+ // Send { card, text } to the owner's direct Kazee chat: the card, else the
157
+ // text. Resolves "card", "text" or null (no Kazee, no owner, nothing sent).
158
+ async function tellOwner({ card, text }, { adapter = kazeeAdapter() } = {}) {
159
+ const ownerId = botProfile.ownerUserId();
160
+ if (!ownerId || !adapter || typeof adapter.directChatFor !== "function") return null;
161
+ const chatId = await adapter.directChatFor(ownerId);
162
+ if (!chatId) return null;
163
+ if (card && typeof adapter.sendCard === "function") {
164
+ try {
165
+ if (await adapter.sendCard(chatId, card, { content: text })) return "card";
166
+ console.error("owner-setup: Kazee Chat returned no message id for the card; sending the text");
167
+ } catch (e) {
168
+ console.error("owner-setup: card not sent, sending the text:", e.message);
169
+ }
170
+ }
171
+ if (!text) return null;
172
+ await adapter.send(chatId, text);
173
+ return "text";
174
+ }
175
+
176
+ // A run that is not the owner's asks at most once a day per card, so nobody
177
+ // else can fill her chat with cards.
178
+ async function tellOwnerAsked(sending, { owner = false, now = Date.now(), adapter } = {}) {
179
+ const key = sending.key || sending.text;
180
+ if (!owner && now - (sentAt.get(key) || 0) < SEND_EVERY_MS) return "already-sent";
181
+ sentAt.set(key, now);
182
+ return tellOwner(sending, adapter ? { adapter } : undefined);
183
+ }
184
+
185
+ // The renew card, once a day while Central says renewDue.
186
+ async function maybeSendRenew({ now = new Date(), adapter } = {}) {
187
+ const today = now.toISOString().slice(0, 10);
188
+ if (!botProfile.renewDue() || lastRenewDay === today) return null;
189
+ lastRenewDay = today;
190
+ try { return await tellOwner(forRenew(), adapter ? { adapter } : undefined); }
191
+ catch (e) { console.error("renew notice failed:", e.message); return null; }
192
+ }
193
+
194
+ // Tests only.
195
+ function reset() {
196
+ sentAt.clear();
197
+ lastRenewDay = "";
198
+ }
199
+
200
+ module.exports = {
201
+ buildSwitchOnCard,
202
+ buildRenewCard,
203
+ switchOnUrl,
204
+ renewUrl,
205
+ forRefusal,
206
+ forRenew,
207
+ plan,
208
+ tellOwner,
209
+ tellOwnerAsked,
210
+ maybeSendRenew,
211
+ botForCard,
212
+ reset,
213
+ SWITCH_ON_REASONS,
214
+ LINK_LABEL_MAX,
215
+ CENTRAL_WEB,
216
+ };
@@ -261,6 +261,24 @@ function buildBrowserAppBlock() {
261
261
  Kazee Chat Browser app: people in a direct chat with you can watch this browser live, take it over, and teach you a task (Teach me). When you need a person in the browser (a login, a captcha, a one-time code, a judgement call), send a card in their direct chat with an Open Browser button, say what you need, then wait: you are woken in that chat when they hand it back. For example \`open-claudia card send '{"v":1,"kind":"report","title":"I need you in the browser","body":"<what you need and why>","open":{"app":"browser","label":"Open Browser"}}'\`. While someone drives, your agent-browser commands other than get, snapshot and screenshot are refused. Never ask for passwords in chat.`;
262
262
  }
263
263
 
264
+ // A personal bot's own instructions (core/bot-profile.js): it works for one
265
+ // person, acts as her in apps, and asks her first for approvals, money and
266
+ // deletes. Stable for the bot, so it lives in the cached core. Autonomous
267
+ // bots keep their current instructions ("").
268
+ // A personal bot also learns how its owner switches it on: one tap on a card,
269
+ // never a page to find (core/owner-setup.js).
270
+ function buildBotKindBlock() {
271
+ try {
272
+ const block = require("./bot-profile").instructionsBlock();
273
+ if (!block) return "";
274
+ return [
275
+ block,
276
+ "- When your owner asks how to let you work for them, or an app refuses you because you're not switched on there, run `open-claudia owner-setup` (add `--apps <app>` when one app is needed; `--renew` to renew) so they get a one-tap card in Kazee Chat.",
277
+ "- Never ask them to find the page by hand. You can never switch yourself on: only they can confirm it.",
278
+ ].join("\n");
279
+ } catch (e) { return ""; }
280
+ }
281
+
264
282
  function buildCoreInstructionsUncached() {
265
283
  const soul = loadSoul();
266
284
  // Gate on actual TTS capability, not local Whisper: hosted pods hear and
@@ -271,6 +289,7 @@ function buildCoreInstructionsUncached() {
271
289
  ${soul}
272
290
  ${buildPersonaBlock()}
273
291
  ${buildLessonsBlock()}
292
+ ${buildBotKindBlock()}
274
293
  ## Open Claudia runtime
275
294
  - Interface: chat through Open Claudia using the selected coding-agent provider.
276
295
  - Voice notes: ${hasVoice ? "enabled" : "disabled"}
@@ -1569,6 +1588,7 @@ const buildSystemPrompt = buildCoreInstructions;
1569
1588
 
1570
1589
  module.exports = {
1571
1590
  browserRuntimeNote,
1591
+ buildBotKindBlock,
1572
1592
  liveCallCameraNote,
1573
1593
  voiceReplyGuidance,
1574
1594
  PromptConstructionError,
package/core/web-otp.js CHANGED
@@ -19,26 +19,35 @@ function _save(list) {
19
19
  try { fs.writeFileSync(OTP_FILE, JSON.stringify(list), { mode: 0o600 }); } catch (e) {}
20
20
  }
21
21
 
22
- function mint(ttlMs = TTL_MS) {
22
+ // `userId` names the person the link is for (the /dashboard command passes
23
+ // the sender). Agent Space's SSO mint names nobody. A personal bot counts a
24
+ // dashboard session as its owner only when it names her (core/access.js).
25
+ function mint(opts = {}) {
26
+ const { ttlMs = TTL_MS, userId = "" } = typeof opts === "number" ? { ttlMs: opts } : (opts || {});
23
27
  const token = crypto.randomBytes(32).toString("hex");
24
28
  const expiresAt = Date.now() + ttlMs;
25
29
  const now = Date.now();
26
30
  const list = _load().filter((e) => e && e.expiresAt > now);
27
- list.push({ token, expiresAt });
31
+ list.push(userId ? { token, expiresAt, userId: String(userId) } : { token, expiresAt });
28
32
  _save(list);
29
33
  return { token, expiresAt };
30
34
  }
31
35
 
32
- // Validate and burn (single use). Returns true only if the token was
33
- // present and unexpired; prunes expired entries either way.
34
- function consume(token) {
35
- if (!token || typeof token !== "string") return false;
36
+ // Validate and burn (single use). Returns the entry ({ userId? }) only if
37
+ // the token was present and unexpired, else null; prunes expired entries
38
+ // either way.
39
+ function take(token) {
40
+ if (!token || typeof token !== "string") return null;
36
41
  const now = Date.now();
37
42
  const list = _load();
38
43
  const idx = list.findIndex((e) => e && e.token === token && e.expiresAt > now);
39
44
  const remaining = list.filter((e, i) => e && e.expiresAt > now && i !== idx);
40
45
  if (remaining.length !== list.length) _save(remaining);
41
- return idx !== -1;
46
+ return idx !== -1 ? list[idx] : null;
47
+ }
48
+
49
+ function consume(token) {
50
+ return !!take(token);
42
51
  }
43
52
 
44
- module.exports = { mint, consume, OTP_FILE, TTL_MS };
53
+ module.exports = { mint, take, consume, OTP_FILE, TTL_MS };