@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.
@@ -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/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)
package/core/loopback.js CHANGED
@@ -108,7 +108,13 @@ 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",
112
+ "view-send", "view-push",
111
113
  ]);
114
+ // view-send carries the page and its preview (each at most 2 MB) as base64.
115
+ const VIEW_FILE_MAX = 2 * 1024 * 1024;
116
+ const VIEW_SEND_BODY_MAX = 6 * 1024 * 1024;
117
+ const VIEW_PUSH_DATA_MAX = 16384;
112
118
 
113
119
  // Who is calling, decided by the bot: the principal of a LIVE run whose
114
120
  // OC_RUN_ATTEST matches (see core/speaker-attest), else UNATTRIBUTED. Nothing
@@ -302,17 +308,121 @@ async function handleCardKind(res, kind, payload, { channelId, adapterId, adapte
302
308
  return reply(res, 400, { error: `unknown card command ${kind}` });
303
309
  }
304
310
 
311
+ // HTML views (Kazee Chat only; core/views.js answers their events). Like a
312
+ // card, a view's events wake the conversation that sent it, so only a live,
313
+ // attributed run may send one, into that run's own room and thread.
314
+ function decodeViewFile(file, what, exts) {
315
+ if (!file || typeof file !== "object") return { error: `missing ${what}` };
316
+ const fileName = path.basename(String(file.fileName || "")).replace(/[^A-Za-z0-9._-]/g, "_");
317
+ const ext = path.extname(fileName).toLowerCase();
318
+ if (!exts.includes(ext)) return { error: `the ${what} must be a ${exts.join(" or ")} file (got ${fileName || "no name"})` };
319
+ if (typeof file.data !== "string" || !file.data) return { error: `the ${what} is empty` };
320
+ const buffer = Buffer.from(file.data, "base64");
321
+ if (!buffer.length) return { error: `the ${what} is empty` };
322
+ if (buffer.length > VIEW_FILE_MAX) return { error: `the ${what} is over 2 MB` };
323
+ return { buffer, fileName, ext };
324
+ }
325
+
326
+ async function handleViewKind(res, kind, payload, { channelId, adapterId, adapter }) {
327
+ const cards = require("./cards");
328
+ const caller = callerOf(payload);
329
+ if (!caller.attributed) {
330
+ return reply(res, 403, { error: "views can only be used from a live, attributed run" });
331
+ }
332
+ const own = (caller.adapterId && registry.findAdapter(caller.adapterId)) || adapter;
333
+ const room = String(caller.channelId || channelId);
334
+ const threadId = threadForCaller(caller, room, payload.threadId);
335
+
336
+ if (kind === "view-send") {
337
+ if (own.type !== "kazee" || typeof own.sendCard !== "function" || typeof own.uploadMedia !== "function") {
338
+ return reply(res, 501, { error: `views are Kazee Chat only (this conversation is on ${own.type || adapterId})` });
339
+ }
340
+ const title = String(payload.title || "").trim();
341
+ if (!title || title.length > 140) return reply(res, 400, { error: "a view needs a --title of at most 140 characters" });
342
+ const body = payload.body ? String(payload.body).trim() : "";
343
+ if (body.length > 600) return reply(res, 400, { error: "--body is over 600 characters" });
344
+ const label = payload.label ? String(payload.label).trim() : "";
345
+ if (label.length > 40) return reply(res, 400, { error: "--label is over 40 characters" });
346
+ const wake = payload.wake === undefined || payload.wake === null || payload.wake === "" ? "each" : String(payload.wake);
347
+ if (!["each", "none"].includes(wake)) return reply(res, 400, { error: "--wake must be each or none" });
348
+ let handler = null;
349
+ if (payload.handler) {
350
+ handler = String(payload.handler);
351
+ if (!path.isAbsolute(handler)) return reply(res, 400, { error: "the handler path must be absolute (the bot process has its own cwd)" });
352
+ let st = null;
353
+ try { st = fs.statSync(handler); } catch (_) {}
354
+ if (!st || !st.isFile()) return reply(res, 400, { error: `handler not found: ${handler}` });
355
+ }
356
+ const page = decodeViewFile(payload.page, "page", [".html", ".htm"]);
357
+ if (page.error) return reply(res, 400, { error: page.error });
358
+ const preview = payload.preview ? decodeViewFile(payload.preview, "preview", [".png", ".jpg", ".jpeg"]) : null;
359
+ if (preview && preview.error) return reply(res, 400, { error: preview.error });
360
+
361
+ let block;
362
+ try {
363
+ const pageId = await own.uploadMedia(room, { buffer: page.buffer, fileName: page.fileName, contentType: "text/html; charset=utf-8" });
364
+ block = { type: "view", page: pageId };
365
+ if (preview) {
366
+ block.preview = await own.uploadMedia(room, {
367
+ buffer: preview.buffer, fileName: preview.fileName, contentType: preview.ext === ".png" ? "image/png" : "image/jpeg",
368
+ });
369
+ }
370
+ if (label) block.label = label;
371
+ } catch (e) {
372
+ return reply(res, 502, { error: `upload failed: ${e.message}` });
373
+ }
374
+ const card = { v: 1, kind: "report", title, ...(body ? { body } : {}), blocks: [block] };
375
+ let messageId;
376
+ try {
377
+ messageId = await own.sendCard(room, card, { threadId });
378
+ } catch (e) {
379
+ return reply(res, 400, { error: e.message });
380
+ }
381
+ if (!messageId) return reply(res, 502, { error: "Kazee Chat returned no message id for the view" });
382
+ // wake "none" on the card path: a view takes no card answers; its events
383
+ // go through core/views.js with the view's own policy.
384
+ cards.recordSentCard({
385
+ messageId, title, kind: "report", wake: "none",
386
+ origin: cardOrigin(caller, payload, own, threadId),
387
+ view: { handler, wake },
388
+ });
389
+ return reply(res, 200, { ok: true, messageId, handler, wake, card });
390
+ }
391
+
392
+ if (kind === "view-push") {
393
+ const rec = cards.getCard(payload.messageId);
394
+ if (!rec || !rec.view) return reply(res, 404, { error: "not a view this bot sent (or older than the card ledger)" });
395
+ const mine = rec.origin && rec.origin.canonicalUserId && rec.origin.canonicalUserId === caller.canonicalUserId;
396
+ if (!mine && caller.owner !== true) return reply(res, 403, { error: "only the conversation's person or the owner can push to this view" });
397
+ const target = (rec.origin && registry.findAdapter(rec.origin.adapter)) || own;
398
+ if (typeof target.pushView !== "function") return reply(res, 501, { error: "views are Kazee Chat only" });
399
+ const data = payload.data === undefined ? null : payload.data;
400
+ if (Buffer.byteLength(JSON.stringify(data)) > VIEW_PUSH_DATA_MAX) return reply(res, 400, { error: "the data is over 16 KB as JSON" });
401
+ try {
402
+ await target.pushView(String(payload.messageId), data, payload.to ? String(payload.to) : undefined);
403
+ } catch (e) {
404
+ return reply(res, 400, { error: e.message });
405
+ }
406
+ return reply(res, 200, { ok: true });
407
+ }
408
+ return reply(res, 400, { error: `unknown view command ${kind}` });
409
+ }
410
+
305
411
  function readBodyAsString(req, max = 64 * 1024) {
306
412
  return new Promise((resolve, reject) => {
307
- let buf = "";
413
+ // Bytes first, text at the end: a big body (view-send) arrives in many
414
+ // chunks, and a chunk may end inside a multi-byte character.
415
+ const chunks = [];
416
+ let size = 0;
308
417
  req.on("data", (d) => {
309
- buf += d.toString();
310
- if (buf.length > max) {
418
+ chunks.push(d);
419
+ size += d.length;
420
+ if (size > max) {
311
421
  req.destroy();
312
422
  reject(new Error("body too large"));
313
423
  }
314
424
  });
315
- req.on("end", () => resolve(buf));
425
+ req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
316
426
  req.on("error", reject);
317
427
  });
318
428
  }
@@ -368,7 +478,7 @@ async function handleSend(req, res, url, kind) {
368
478
  }
369
479
 
370
480
  async function handleJson(req, res, url, kind) {
371
- const body = await readBodyAsString(req);
481
+ const body = await readBodyAsString(req, kind === "view-send" ? VIEW_SEND_BODY_MAX : undefined);
372
482
  let payload = {};
373
483
  if (body.trim()) {
374
484
  try { payload = JSON.parse(body); }
@@ -439,6 +549,7 @@ async function handleJson(req, res, url, kind) {
439
549
  maxHops: out.maxHops,
440
550
  source: out.source,
441
551
  target: out.target,
552
+ newThread: out.newThread === true,
442
553
  });
443
554
  } catch (e) {
444
555
  return reply(res, e.status || 500, { error: e.message });
@@ -452,6 +563,29 @@ async function handleJson(req, res, url, kind) {
452
563
  return handleCardKind(res, kind, payload, { channelId, adapterId, adapter });
453
564
  }
454
565
 
566
+ if (kind === "view-send" || kind === "view-push") {
567
+ return handleViewKind(res, kind, payload, { channelId, adapterId, adapter });
568
+ }
569
+ // `open-claudia owner-setup` (core/owner-setup.js): the one-tap card that
570
+ // lets a personal bot's owner switch it on or renew it. It always goes to
571
+ // HER direct chat, never this room; the bot decides what it says from its
572
+ // own profile. A run that is not hers sends each card at most once a day.
573
+ if (kind === "owner-setup") {
574
+ const caller = callerOf(payload);
575
+ if (!caller.attributed) return reply(res, 403, { error: "owner-setup can only be used from a live, attributed run" });
576
+ const ownerSetup = require("./owner-setup");
577
+ const planned = ownerSetup.plan({ apps: Array.isArray(payload.apps) ? payload.apps.map(String) : [], renew: payload.renew === true });
578
+ if (planned.refused) return reply(res, 400, { error: planned.refused });
579
+ if (planned.nothing) return reply(res, 200, { ok: true, sent: null, nothing: planned.nothing });
580
+ try {
581
+ const sent = await ownerSetup.tellOwnerAsked(planned, { owner: caller.owner === true });
582
+ if (!sent) return reply(res, 502, { error: "could not reach the owner's direct chat in Kazee" });
583
+ return reply(res, 200, { ok: true, sent, title: planned.card ? planned.card.title : null });
584
+ } catch (e) {
585
+ return reply(res, 502, { error: e.message });
586
+ }
587
+ }
588
+
455
589
  if (kind === "call-say") {
456
590
  try {
457
591
  const result = await require("./call-speech").speak({ ...payload, channelId, userId, adapter: adapterId });
@@ -257,8 +257,12 @@ function acquireLock(root) {
257
257
  let stale = false;
258
258
  try {
259
259
  const stat = assertRegularFile(filePath, "MIGRATION_LOCKED", "migration lock");
260
- const pid = Number(fs.readFileSync(filePath, "utf8").trim());
261
- if (Number.isSafeInteger(pid) && pid > 0 && Date.now() - stat.mtimeMs > 1000) {
260
+ const text = fs.readFileSync(filePath, "utf8").trim();
261
+ const pid = Number(text);
262
+ // An old empty lock was left by a run that died before writing its pid
263
+ // (kazee-people, Oct 2026: 817 restarts); nobody can hold it.
264
+ if (!text && Date.now() - stat.mtimeMs > 1000) stale = true;
265
+ else if (Number.isSafeInteger(pid) && pid > 0 && Date.now() - stat.mtimeMs > 1000) {
262
266
  // We haven't taken the lock yet, so one naming our own pid was left by an
263
267
  // earlier run that died holding it. In a container the bot is pid 1 after
264
268
  // every restart, so the liveness probe below would find "us" alive forever.
@@ -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
+ };
@@ -682,6 +682,49 @@ function listDeferredDecisions() {
682
682
  } catch (_) { return []; }
683
683
  }
684
684
 
685
+ // In-process FIFO in front of the review model: a busy bot runs reviews one at a
686
+ // time instead of stacking model processes (OC_REVIEW_CONCURRENCY, default 1).
687
+ // Utility calls over their cap are rejected, not queued, so lowering the cap
688
+ // would lose reviews. acquire() hands back a release function, at once when a
689
+ // slot is free and otherwise as a promise; a waiter pushed out by the bound
690
+ // (OC_REVIEW_QUEUE_MAX, default 20, oldest first) gets "dropped", and one still
691
+ // waiting at shutdown gets "closed".
692
+ function reviewSetting(key, fallback) {
693
+ const value = Number(require("./config").settingValue(key));
694
+ return Number.isSafeInteger(value) && value > 0 ? value : fallback;
695
+ }
696
+
697
+ function createReviewGate({ limit, maxWaiting }) {
698
+ let active = 0;
699
+ let closed = false;
700
+ const waiting = [];
701
+ function grant() {
702
+ active += 1;
703
+ let released = false;
704
+ return () => {
705
+ if (released) return;
706
+ released = true;
707
+ active -= 1;
708
+ if (waiting.length) waiting.shift()(grant());
709
+ };
710
+ }
711
+ return {
712
+ acquire() {
713
+ if (closed) return "closed";
714
+ if (active < limit) return grant();
715
+ if (waiting.length >= maxWaiting) {
716
+ waiting.shift()("dropped");
717
+ console.warn(`[pack-review] review queue full (${maxWaiting} waiting): dropped the oldest waiting review`);
718
+ }
719
+ return new Promise((resolve) => waiting.push(resolve));
720
+ },
721
+ close() {
722
+ closed = true;
723
+ for (const resolve of waiting.splice(0)) resolve("closed");
724
+ },
725
+ };
726
+ }
727
+
685
728
  function createPackReviewer(dependencies = {}) {
686
729
  const runUtility = dependencies.spawnUtilityAgent || spawnUtilityAgent;
687
730
  const queue = dependencies.mutationQueue || memoryMutationQueue;
@@ -689,6 +732,10 @@ function createPackReviewer(dependencies = {}) {
689
732
  const lockRetryDelayMs = dependencies.lockRetryDelayMs ?? LOCK_RETRY_DELAY_MS;
690
733
  const adjudicator = dependencies.dedupAdjudicator
691
734
  || dedupAdjudicator.createDedupAdjudicator({ spawnUtilityAgent: dependencies.spawnUtilityAgent });
735
+ const gate = createReviewGate({
736
+ limit: reviewSetting("OC_REVIEW_CONCURRENCY", 1),
737
+ maxWaiting: reviewSetting("OC_REVIEW_QUEUE_MAX", 20),
738
+ });
692
739
  const handles = new Map();
693
740
  let accepting = true;
694
741
 
@@ -712,19 +759,34 @@ function createPackReviewer(dependencies = {}) {
712
759
  }
713
760
 
714
761
  const capturedProvider = provider || null;
715
- const versions = snapshotMemoryVersions();
716
- // Denies are the OWNER's refusals — mine them only when reviewing an
717
- // owner turn, so deny content never surfaces via external speakers' turns.
718
- const denies = (!source || source.isOwner) ? collectDeniedApprovals() : { block: "", ids: [] };
719
- const prompt = buildReviewPrompt(
720
- redactSensitive(String(userText || "")),
721
- redactSensitive(String(assistantText || "")),
722
- activePacks,
723
- denies.block,
724
- );
725
762
  const work = (async () => {
726
763
  let utilityResult = null;
764
+ let releaseSlot = () => {};
727
765
  try {
766
+ // The review's own timeout starts at the model call, after this wait.
767
+ let slot = gate.acquire();
768
+ if (slot instanceof Promise) slot = await slot;
769
+ if (slot === "dropped") {
770
+ reviewerMetric({ provider: capturedProvider || "", status: "dropped", code: "REVIEW_QUEUE_FULL" });
771
+ return { status: "dropped", reason: "review queue full" };
772
+ }
773
+ if (slot === "closed") {
774
+ queue.record({ id: null, label: "review", status: "pending_intent_rejected", provider: capturedProvider, runId: runId || null });
775
+ return { status: "not_admitted", reason: "review shutdown in progress" };
776
+ }
777
+ releaseSlot = slot;
778
+ // Read memory after the wait: a snapshot from before it would make this
779
+ // review's rewrites stale whenever the review ahead touched the same pack.
780
+ const versions = snapshotMemoryVersions();
781
+ // Denies are the OWNER's refusals — mine them only when reviewing an
782
+ // owner turn, so deny content never surfaces via external speakers' turns.
783
+ const denies = (!source || source.isOwner) ? collectDeniedApprovals() : { block: "", ids: [] };
784
+ const prompt = buildReviewPrompt(
785
+ redactSensitive(String(userText || "")),
786
+ redactSensitive(String(assistantText || "")),
787
+ activePacks,
788
+ denies.block,
789
+ );
728
790
  utilityResult = await runUtility(prompt, {
729
791
  purpose: "review",
730
792
  activeProvider: capturedProvider,
@@ -754,6 +816,8 @@ function createPackReviewer(dependencies = {}) {
754
816
  try {
755
817
  dedup = await adjudicator.adjudicateDecision(decision, { provider: capturedProvider, cwd: process.cwd() });
756
818
  } catch (_) {}
819
+ // Model runs are done; the wait for the write lock does not hold the slot.
820
+ releaseSlot();
757
821
  const finalDecision = dedup ? dedup.decision : decision;
758
822
  const enqueueApply = () => queue.enqueue("review", () => applyDecisionBatch({
759
823
  decision: finalDecision,
@@ -822,6 +886,8 @@ function createPackReviewer(dependencies = {}) {
822
886
  announcementFailed,
823
887
  error: { code: error.code || "REVIEW_FAILED", message: redactSensitive(error.message) },
824
888
  };
889
+ } finally {
890
+ releaseSlot();
825
891
  }
826
892
  })();
827
893
  return track(work, { provider: capturedProvider, channelId: channelId || null, runId: runId || null });
@@ -829,6 +895,7 @@ function createPackReviewer(dependencies = {}) {
829
895
 
830
896
  function stopAdmission(reason = "shutdown") {
831
897
  accepting = false;
898
+ gate.close();
832
899
  queue.record({ id: null, label: "review", status: "review_admission_stopped", reason: String(reason).slice(0, 160) });
833
900
  }
834
901
 
@@ -4,7 +4,7 @@
4
4
  // Each engine implements: async run(ctx) -> { packBlock, entityBlock,
5
5
  // packMatches, entityMatches }. The active engine is chosen per channel via
6
6
  // the `recallEngine` setting (set by the /engine slash command), falling back
7
- // to the RECALL_ENGINE env var, then to DEFAULT_ENGINE. Unknown names fall
7
+ // to RECALL_ENGINE (process env or the bot's .env), then to DEFAULT_ENGINE. Unknown names fall
8
8
  // back to the default so a bad value can never break recall. The discoverer is
9
9
  // the default: it's fail-open (falls back to the classic keyword baseline on
10
10
  // any error), so it never recalls worse than classic, only better. `ask`
@@ -37,7 +37,8 @@ function listEngines() {
37
37
 
38
38
  function activeEngineName(settings) {
39
39
  const fromSettings = settings && settings.recallEngine;
40
- const fromEnv = process.env.RECALL_ENGINE;
40
+ let fromEnv;
41
+ try { fromEnv = require("../config").settingValue("RECALL_ENGINE"); } catch (e) { fromEnv = process.env.RECALL_ENGINE; }
41
42
  const name = String(fromSettings || fromEnv || DEFAULT_ENGINE).toLowerCase();
42
43
  return ENGINES[name] ? name : DEFAULT_ENGINE;
43
44
  }