@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 +31 -0
- package/Dockerfile +2 -0
- package/bin/cli.js +47 -0
- package/bin/handoff.js +12 -3
- package/bin/owner-setup.js +56 -0
- package/bin/views.js +255 -0
- package/bot.js +42 -0
- package/channels/kazee/adapter.js +61 -1
- package/channels/kazee/call/camera.js +1 -2
- package/channels/kazee/call/session.js +6 -5
- package/channels/spaces/adapter.js +25 -1
- package/channels/spaces/client.js +6 -2
- package/channels/telegram/adapter.js +2 -1
- package/channels/web/adapter.js +27 -11
- package/core/access.js +25 -0
- package/core/actions.js +8 -0
- package/core/app-pass.js +145 -0
- package/core/bot-profile.js +228 -0
- package/core/config.js +7 -0
- package/core/connected-apps.js +19 -4
- package/core/handlers.js +3 -1
- package/core/identity.js +14 -0
- package/core/lessons.js +2 -1
- package/core/loopback.js +139 -5
- package/core/migration-backup.js +6 -2
- package/core/owner-setup.js +216 -0
- package/core/pack-review.js +77 -10
- package/core/recall/index.js +3 -2
- package/core/recall/warm-walker.js +31 -1
- package/core/runner.js +4 -1
- package/core/system-prompt.js +22 -1
- package/core/thread-handoff.js +44 -8
- package/core/tool-guard.js +15 -8
- package/core/usage-log.js +1 -0
- package/core/views.js +310 -0
- package/core/web-otp.js +17 -8
- package/core/web-sessions.js +11 -2
- package/docker-entrypoint.sh +10 -0
- package/package.json +2 -2
- package/test-provider-language.js +5 -1
- package/test-provider-migration-backup.js +13 -0
- package/web.js +19 -8
package/core/connected-apps.js
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
310
|
-
|
|
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(
|
|
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 });
|
package/core/migration-backup.js
CHANGED
|
@@ -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
|
|
261
|
-
|
|
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
|
+
};
|
package/core/pack-review.js
CHANGED
|
@@ -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
|
|
package/core/recall/index.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
}
|