@inetafrica/open-claudia 3.5.75 → 3.5.77

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/core/loopback.js CHANGED
@@ -109,7 +109,12 @@ const JSON_KINDS = new Set([
109
109
  "say", "call-say", "call", "tts", "transcribe",
110
110
  "card-send", "card-update", "card-show", "decide-add", "decide-list", "decide-cancel",
111
111
  "owner-setup",
112
+ "view-send", "view-push",
112
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;
113
118
 
114
119
  // Who is calling, decided by the bot: the principal of a LIVE run whose
115
120
  // OC_RUN_ATTEST matches (see core/speaker-attest), else UNATTRIBUTED. Nothing
@@ -303,17 +308,121 @@ async function handleCardKind(res, kind, payload, { channelId, adapterId, adapte
303
308
  return reply(res, 400, { error: `unknown card command ${kind}` });
304
309
  }
305
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
+
306
411
  function readBodyAsString(req, max = 64 * 1024) {
307
412
  return new Promise((resolve, reject) => {
308
- 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;
309
417
  req.on("data", (d) => {
310
- buf += d.toString();
311
- if (buf.length > max) {
418
+ chunks.push(d);
419
+ size += d.length;
420
+ if (size > max) {
312
421
  req.destroy();
313
422
  reject(new Error("body too large"));
314
423
  }
315
424
  });
316
- req.on("end", () => resolve(buf));
425
+ req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
317
426
  req.on("error", reject);
318
427
  });
319
428
  }
@@ -369,7 +478,7 @@ async function handleSend(req, res, url, kind) {
369
478
  }
370
479
 
371
480
  async function handleJson(req, res, url, kind) {
372
- const body = await readBodyAsString(req);
481
+ const body = await readBodyAsString(req, kind === "view-send" ? VIEW_SEND_BODY_MAX : undefined);
373
482
  let payload = {};
374
483
  if (body.trim()) {
375
484
  try { payload = JSON.parse(body); }
@@ -440,6 +549,7 @@ async function handleJson(req, res, url, kind) {
440
549
  maxHops: out.maxHops,
441
550
  source: out.source,
442
551
  target: out.target,
552
+ newThread: out.newThread === true,
443
553
  });
444
554
  } catch (e) {
445
555
  return reply(res, e.status || 500, { error: e.message });
@@ -453,6 +563,9 @@ async function handleJson(req, res, url, kind) {
453
563
  return handleCardKind(res, kind, payload, { channelId, adapterId, adapter });
454
564
  }
455
565
 
566
+ if (kind === "view-send" || kind === "view-push") {
567
+ return handleViewKind(res, kind, payload, { channelId, adapterId, adapter });
568
+ }
456
569
  // `open-claudia owner-setup` (core/owner-setup.js): the one-tap card that
457
570
  // lets a personal bot's owner switch it on or renew it. It always goes to
458
571
  // HER direct chat, never this room; the bot decides what it says from its
@@ -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.
@@ -16,6 +16,9 @@ const PRICES = {
16
16
  "claude-opus-4-8": [5, 25, 0.5, 6.25, 10],
17
17
  "claude-opus-4-7": [5, 25, 0.5, 6.25, 10],
18
18
  "claude-opus-4-6": [5, 25, 0.5, 6.25, 10],
19
+ // Fable 5.2 (9 Nov 2026, pricing page read 11 Nov): without its own row it
20
+ // fell through to the claude-fable-5 prefix and was billed 10/50, cache 1.
21
+ "claude-fable-5-2": [8, 40, 0.2, 10, 16],
19
22
  "claude-fable-5-1": [10, 50, 0.25, 12.5, 20],
20
23
  "claude-fable-5": [10, 50, 1, 12.5, 20],
21
24
  "claude-sonnet-5-5": [2, 10, 0.1, 2.5, 4],
@@ -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
 
@@ -6,6 +6,8 @@ const { createJsonlDecoder } = require("./events");
6
6
  const { createClaudeParserState, normalizeClaudeEvent } = require("./claude-events");
7
7
  const { createClaudeHookTransport } = require("./claude-hook");
8
8
 
9
+ // claude-fable-5-2 (9 Nov 2026) answers on Claude Code 2.1.302 when named in
10
+ // full (checked 10 Nov); the short name "fable" only means 5.2 from 2.1.321.
9
11
  // New models are gated on the CLI build: claude-fable-5-1 requires Claude Code
10
12
  // >= 2.1.251, claude-opus-5-5 requires >= 2.1.280. An older CLI rejects them with
11
13
  // an API 400 rather than falling back, so a pod on a stale image will error on
@@ -28,6 +30,7 @@ const { createClaudeHookTransport } = require("./claude-hook");
28
30
  const CLAUDE_LOW_TIER_MODEL = "claude-haiku-5-5";
29
31
  const CLAUDE_MEDIUM_TIER_MODEL = "claude-sonnet-5-5";
30
32
  const CLAUDE_MODELS = Object.freeze([
33
+ "claude-fable-5-2",
31
34
  "claude-fable-5-1",
32
35
  "claude-fable-5",
33
36
  "claude-opus-5-5",
@@ -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
  }
@@ -16,6 +16,9 @@ const MAX_CHARS = Number(process.env.RECALL_WARM_WALKER_MAX_CHARS || 24000);
16
16
  const DEFAULT_TIMEOUT_MS = 25000;
17
17
  const KILL_GRACE_MS = Number(process.env.RECALL_WARM_WALKER_KILL_GRACE_MS || 250);
18
18
  const MAX_BUFFER_BYTES = Number(process.env.RECALL_WARM_WALKER_MAX_BUFFER_BYTES || 1024 * 1024);
19
+ // The child holds about 160 MB doing nothing; stop it this long after the last
20
+ // walk ends (0 = never). The next walk respawns it.
21
+ const IDLE_MS = Number(process.env.RECALL_WARM_WALKER_IDLE_MS || 10 * 60 * 1000);
19
22
 
20
23
  let child = null;
21
24
  let childProviderId = null;
@@ -26,6 +29,8 @@ let charCount = 0;
26
29
  let costTotal = 0;
27
30
  let childModel = null;
28
31
  let stopPromise = null;
32
+ let idleTimer = null;
33
+ let walksInHand = 0;
29
34
 
30
35
  function isEnabled(provider) {
31
36
  if (String(process.env.RECALL_WARM_WALKER || "on").toLowerCase() === "off") return false;
@@ -70,6 +75,22 @@ function stopChild(reason = "warm walker stopped") {
70
75
  return stopPromise;
71
76
  }
72
77
 
78
+ function clearIdleTimer() {
79
+ if (idleTimer) clearTimeout(idleTimer);
80
+ idleTimer = null;
81
+ }
82
+
83
+ function armIdleTimer() {
84
+ clearIdleTimer();
85
+ if (!(IDLE_MS > 0) || !child) return;
86
+ idleTimer = setTimeout(() => {
87
+ idleTimer = null;
88
+ if (pending || walksInHand) return;
89
+ stopChild("warm walker idle").catch(() => {});
90
+ }, IDLE_MS);
91
+ idleTimer.unref();
92
+ }
93
+
73
94
  function persistentInvocation(cfg) {
74
95
  const provider = cfg.provider;
75
96
  if (!isEnabled(provider)) throw new Error(`${provider?.id || "provider"} has no enabled persistent utility stream`);
@@ -165,6 +186,8 @@ function spawnChild(cfg) {
165
186
 
166
187
  async function doWalk(promptText, opts) {
167
188
  if (!isEnabled(opts.provider)) throw new Error(`${opts.provider?.id || "provider"} persistent recall is unavailable`);
189
+ // An idle stop still closing: its close must not land on this walk.
190
+ if (stopPromise) await stopPromise;
168
191
  if (child && (childProviderId !== opts.provider.id || msgCount >= MAX_MSGS || charCount >= MAX_CHARS)) {
169
192
  await stopChild("warm walker recycled");
170
193
  }
@@ -193,13 +216,20 @@ async function doWalk(promptText, opts) {
193
216
  }
194
217
 
195
218
  function walkWarm(promptText, opts = {}) {
219
+ clearIdleTimer();
220
+ walksInHand += 1;
196
221
  const run = () => doWalk(promptText, opts);
197
222
  const result = chain.then(run, run);
198
- chain = result.then(() => {}, () => {});
223
+ const walkEnded = () => {
224
+ walksInHand -= 1;
225
+ if (!walksInHand) armIdleTimer();
226
+ };
227
+ chain = result.then(walkEnded, walkEnded);
199
228
  return result;
200
229
  }
201
230
 
202
231
  async function shutdown() {
232
+ clearIdleTimer();
203
233
  await stopChild("warm walker shutdown");
204
234
  }
205
235
 
package/core/runner.js CHANGED
@@ -49,6 +49,7 @@ const packReview = require("./pack-review");
49
49
  const {
50
50
  appendUsageRecord,
51
51
  loadUsageHistory,
52
+ usageAlertsOn,
52
53
  usageAlertPolicy,
53
54
  evaluateUsageAlert,
54
55
  } = require("./usage-log");
@@ -234,7 +235,9 @@ function logTurnUsage(state, usage, costUsd, announce, opts = {}) {
234
235
  record.rawCacheReadTokens = raw.cacheRead;
235
236
  record.rawContextTokens = raw.context;
236
237
  }
237
- const previousRecords = loadUsageHistory();
238
+ // Only an alert reads the history, and parsing the whole file every turn is
239
+ // not free.
240
+ const previousRecords = usageAlertsOn(config) ? loadUsageHistory() : [];
238
241
  appendUsageRecord(record, { strict: !!opts.strict });
239
242
 
240
243
  const policy = usageAlertPolicy(config);
@@ -319,6 +319,7 @@ Reply normally in your final answer. To send a file, image, or voice clip back t
319
319
  - \`open-claudia send-voice <path>\` — ogg/opus voice note
320
320
  - \`open-claudia say "<text>"\` — synthesize the text and send it as a voice note (use this when asked for a voice reply)
321
321
  - \`open-claudia card send <spec.json|->\` — (Kazee Chat) a decision, choice (poll), rating, slider, form or report card; answers wake this conversation. Cards are built from pieces: blocks (text, stats, bars, facts, table) and form questions (choice, rating, text, tick, date, slots, rows with a tick/date/option per row, board). When a reply would list items for someone to tick, date, pick, approve or move, or asks one person 2+ questions, send ONE card built from pieces instead of a text list plus a separate question; \`open-claudia card pieces\` lists them, \`card example <name>\` prints a spec, \`card update <id> --close | --note "..."\` changes one you sent.
322
+ - \`open-claudia view send <page.html> --title "<title>" [--handler <file>]\` — (Kazee Chat) an interactive web page, sent as a card with an Open button; it opens in a walled-off panel. The page can load libraries from cdn.jsdelivr.net, unpkg.com, cdnjs.cloudflare.com or esm.sh but cannot fetch data from the internet, so put the data in the page. It talks to you with \`kazee.send(name, data)\` (wakes this conversation, or a --handler script answers fast) and \`kazee.on(fn)\` (gets what you \`view push\`); \`open-claudia view example\` shows a page and a handler.
322
323
  - \`open-claudia decide "<question>" --option yes="..." --option no="..." [--pick yes] [--body "why"]\` — (owner, Kazee Chat) queue a decision for the owner as a card: one is out at a time, his answer wakes this conversation. Prefer it to asking a yes/no question in text when work waits on his decision.
323
324
  Never print or embed bot tokens in prompts, commands, logs, or messages.
324
325
  ${hasVoice || hasEars ? `
@@ -334,7 +335,7 @@ These survive restarts and are the ONLY timers that do: the provider's own sched
334
335
  - Wakeups and crons: \`open-claudia schedule-wakeup <30s|5m|2h|1d|ISO> "<prompt>"\` (you wake in this conversation with the prompt), \`open-claudia cron-add "<5-field cron>" "<prompt>"\`, \`cron-list\`, \`cron-remove <id>\`.
335
336
  - Watches: \`open-claudia watch-add "<check-cmd>" "<wake-prompt>"\` polls silently and wakes you once the check exits 0 (one-shot; \`--repeat\`, \`--every\`, \`--timeout\` default 2h, an expired watch still fires marked "Watch expired"). For EXTERNAL state only. \`watch-list\` / \`watch-remove <id>\`.
336
337
  - Tasks: \`open-claudia task plan|add|list|start|done|remove\` is the persistent per-channel list (the provider's own todo tools are not). Plan work that may outlive this turn and mark each step as you go; \`done\` deletes the entry. Skip it for work that finishes this turn.
337
- - Relay and hand-off: \`open-claudia send-to --person "<name>" "<msg>"\` (prefix "From <speaker>:" when forwarding, "Open Claudia:" when acting on your own; sparingly, audited). \`open-claudia handoff --thread <id> "<msg>"\` moves work to another Kazee thread (owner only).
338
+ - Relay and hand-off: \`open-claudia send-to --person "<name>" "<msg>"\` (prefix "From <speaker>:" when forwarding, "Open Claudia:" when acting on your own; sparingly, audited). \`open-claudia handoff --thread <id> "<msg>"\` moves work to another Kazee thread (owner only); \`handoff --new "<title>" "<msg>"\` starts a titled thread tagging the asker (never hand off onto a plain main-chat message).
338
339
  - Lookups: \`open-claudia recent --person "<name>"\`; \`open-claudia people list|show|note\`; \`open-claudia intros list|approve|reject\` (owner).
339
340
 
340
341
  ## Context packs (your long-term knowledge)
@@ -27,6 +27,8 @@ const MAX_HOPS = 3;
27
27
  const MAX_MESSAGE_CHARS = 4000;
28
28
  const MAX_LABEL_CHARS = 60;
29
29
  const MAX_SHOWN_TITLE_CHARS = 80;
30
+ // chat-central thread titles (kazee-chat thread-start): 1-80 characters.
31
+ const MAX_NEW_TITLE_CHARS = 80;
30
32
  // Kazee chat and thread ids are 24-hex Mongo ids; anything outside this set
31
33
  // could not be one and could bend a conversation key ("#thread:").
32
34
  const ID_RE = /^[A-Za-z0-9_-]{1,64}$/;
@@ -73,9 +75,20 @@ function sourceName(source) {
73
75
  }
74
76
 
75
77
  // What everyone in the target thread sees: plain, readable on a phone.
76
- function visibleText({ source, message }) {
78
+ function visibleText({ source, message, tag = null }) {
77
79
  const sep = message.includes("\n") || message.length > 120 ? "\n" : " ";
78
- return `↪️ Hand-off from ${sourceName(source)}:${sep}${message}`;
80
+ const who = tag ? `@[${tag.name}](${tag.id}) ` : "";
81
+ return `${who}↪️ Hand-off from ${sourceName(source)}:${sep}${message}`;
82
+ }
83
+
84
+ // The person a new thread is for: the one whose turn asked, tagged so the
85
+ // thread shows in their Threads list (24 Oct 2026: Cynthia's bot handed off
86
+ // onto a plain main-chat message; untitled and untagged, nobody could find it).
87
+ function requesterTag(caller) {
88
+ const id = caller && caller.userId ? String(caller.userId) : "";
89
+ if (!/^[a-f0-9]{24}$/i.test(id)) return null;
90
+ const name = oneLine((caller.name || "you").replace(/[\[\]()]/g, ""), 60) || "you";
91
+ return { id, name };
79
92
  }
80
93
 
81
94
  function quote(message) {
@@ -135,10 +148,17 @@ async function handOff({ caller, payload = {}, registry }, deps = {}) {
135
148
  const sourceThreadId = caller.threadId ? String(caller.threadId) : null;
136
149
  if (!sourceChatId) throw new HandoffError(400, "cannot tell which Kazee chat this turn is in");
137
150
 
138
- const targetThreadId = String(payload.targetThreadId || "").trim();
151
+ let targetThreadId = String(payload.targetThreadId || "").trim();
139
152
  const targetChatId = String(payload.targetChatId || "").trim() || sourceChatId;
140
- if (!targetThreadId) throw new HandoffError(400, "missing --thread <threadId> (a hand-off goes to a Kazee thread)");
141
- if (!ID_RE.test(targetThreadId)) throw new HandoffError(400, `"${oneLine(targetThreadId, 40)}" is not a Kazee thread id (the thread's root message id)`);
153
+ const newTitle = payload.newThreadTitle == null ? "" : oneLine(payload.newThreadTitle, 200);
154
+ if (payload.newThreadTitle != null) {
155
+ if (targetThreadId) throw new HandoffError(400, "use --thread <threadId> for an existing thread or --new \"<title>\" for a new one, not both");
156
+ if (!newTitle) throw new HandoffError(400, "a new thread needs a title: --new \"<title>\"");
157
+ if (newTitle.length > MAX_NEW_TITLE_CHARS) throw new HandoffError(400, `the new thread's title is ${newTitle.length} characters; at most ${MAX_NEW_TITLE_CHARS}`);
158
+ targetThreadId = "(new)";
159
+ }
160
+ if (!targetThreadId) throw new HandoffError(400, "missing --thread <threadId> or --new \"<title>\" (a hand-off goes to a Kazee thread)");
161
+ if (!newTitle && !ID_RE.test(targetThreadId)) throw new HandoffError(400, `"${oneLine(targetThreadId, 40)}" is not a Kazee thread id (the thread's root message id)`);
142
162
  if (!ID_RE.test(targetChatId)) throw new HandoffError(400, `"${oneLine(targetChatId, 40)}" is not a Kazee chat id`);
143
163
  if (targetChatId === sourceChatId && targetThreadId === sourceThreadId) {
144
164
  throw new HandoffError(400, "that is the thread you are in: a hand-off goes to another thread");
@@ -155,7 +175,18 @@ async function handOff({ caller, payload = {}, registry }, deps = {}) {
155
175
  throw new HandoffError(409, `loop guard: ${MAX_HOPS} hand-offs already ran in a row without a person writing, so this one is refused. Report in your reply instead; the person's next message starts a new chain.`);
156
176
  }
157
177
 
158
- const targetThread = await readThread(adapter, targetChatId, targetThreadId);
178
+ // A new thread: a real titled thread (never a plain main-chat message),
179
+ // started only once every refusal above has passed.
180
+ const tag = newTitle ? requesterTag(caller) : null;
181
+ if (newTitle) {
182
+ if (typeof adapter.startThread !== "function") throw new HandoffError(501, "this Kazee adapter cannot start threads");
183
+ try {
184
+ targetThreadId = await adapter.startThread(targetChatId, newTitle);
185
+ } catch (e) {
186
+ throw new HandoffError(502, `Kazee Chat did not start the thread "${newTitle}": ${String((e && e.message) || e).slice(0, 200)}; nothing was posted or woken`);
187
+ }
188
+ }
189
+ const targetThread = newTitle ? { ok: true, title: newTitle } : await readThread(adapter, targetChatId, targetThreadId);
159
190
  if (!targetThread.ok) {
160
191
  throw new HandoffError(404, `could not open thread ${targetThreadId} in chat ${targetChatId}: ${targetThread.error}`);
161
192
  }
@@ -173,7 +204,9 @@ async function handOff({ caller, payload = {}, registry }, deps = {}) {
173
204
  key: conversationKeyFor("kazee", targetChatId, targetThreadId),
174
205
  };
175
206
 
176
- const sent = normalizeSendResult(await adapter.send(targetChatId, visibleText({ source, message }), { threadId: targetThreadId }));
207
+ const sendOpts = { threadId: targetThreadId };
208
+ if (tag) sendOpts.mentions = [tag.id];
209
+ const sent = normalizeSendResult(await adapter.send(targetChatId, visibleText({ source, message, tag }), sendOpts));
177
210
  if (!sent.ok) {
178
211
  throw new HandoffError(502, "Kazee Chat did not take the hand-off message (details in bot.log); nothing was woken");
179
212
  }
@@ -230,6 +263,7 @@ async function handOff({ caller, payload = {}, registry }, deps = {}) {
230
263
  to: target.key,
231
264
  requestedBy,
232
265
  messageId: sent.messageId,
266
+ newThread: Boolean(newTitle),
233
267
  at,
234
268
  },
235
269
  });
@@ -241,15 +275,17 @@ async function handOff({ caller, payload = {}, registry }, deps = {}) {
241
275
  by: caller.canonicalUserId || null, from: source.key, to: target.key, hop, jobId: job && job.id, messageId: sent.messageId,
242
276
  });
243
277
  } catch (_) { /* the audit trail is best-effort */ }
244
- return { messageId: sent.messageId, job, hop, maxHops: MAX_HOPS, source, target };
278
+ return { messageId: sent.messageId, job, hop, maxHops: MAX_HOPS, source, target, newThread: Boolean(newTitle) };
245
279
  }
246
280
 
247
281
  module.exports = {
248
282
  MAX_HOPS,
249
283
  MAX_MESSAGE_CHARS,
284
+ MAX_NEW_TITLE_CHARS,
250
285
  HandoffError,
251
286
  handOff,
252
287
  priorHop,
288
+ requesterTag,
253
289
  visibleText,
254
290
  wakePrompt,
255
291
  };
@@ -1008,14 +1008,18 @@ function browserDriverDenial(command, now = Date.now()) {
1008
1008
  // file, a runner this does not know). Deliberately ahead of the relaxed
1009
1009
  // tooling-mode switch: it protects the machine, it is not a workflow
1010
1010
  // preference. Any error here allows: the net still holds.
1011
- const TEST_RUNNERS = new Set(["jest", "vitest"]);
1011
+ // 6 Nov 2026: helpers on the tickets bot ran tsc on three repos at once, about
1012
+ // 700 MB each, 2,887 of its 3,072 MiB. tsc has no workers, so it only takes its
1013
+ // turn with the test runs and honours the memory floor.
1014
+ const TEST_RUNNERS = new Set(["jest", "vitest", "tsc"]);
1012
1015
  const PACKAGE_MANAGERS = new Set(["npm", "yarn", "pnpm", "bun"]);
1013
1016
  const TEST_RUN_CLAIM_MS = 30000;
1014
1017
  const NODE_VALUE_FLAGS = new Set(["-r", "--require", "--import", "--loader", "--experimental-loader", "--env-file", "--conditions", "-C"]);
1015
1018
  const NOT_A_RUN_FLAGS = new Set(["--listTests", "--showConfig", "--version", "-v", "--help", "-h", "--clearCache"]);
1019
+ const TSC_NOT_A_RUN_FLAGS = new Set(["--init", "--all"]);
1016
1020
  // A process that IS a test run (not a shell or grep that mentions one).
1017
- const TEST_PROCESS_RE = /(?:^|[\s/])(?:jest|jest-cli|vitest)(?:\.c?m?js)?(?=\s|$)|jest-worker\/|vitest\/dist\/|tinypool\//;
1018
- const TEST_PROCESS_BINS = new Set(["node", "nodejs", "jest", "vitest", "npm", "npx", "yarn", "pnpm", "pnpx", "bun", "bunx"]);
1021
+ const TEST_PROCESS_RE = /(?:^|[\s/])(?:jest|jest-cli|vitest|tsc)(?:\.c?m?js)?(?=\s|$)|jest-worker\/|vitest\/dist\/|tinypool\//;
1022
+ const TEST_PROCESS_BINS = new Set(["node", "nodejs", "jest", "vitest", "tsc", "npm", "npx", "yarn", "pnpm", "pnpx", "bun", "bunx"]);
1019
1023
 
1020
1024
  function positiveInt(value, fallback) {
1021
1025
  const n = Number(value);
@@ -1110,7 +1114,7 @@ function packageScriptRun(words, at, pkg) {
1110
1114
  // and what a shell runs from a string (bash -c, eval, $(...)).
1111
1115
  function testRunInvocations(command, cwd, depth = 0) {
1112
1116
  const runs = [];
1113
- if (depth > 4 || !/jest|vitest|test|run|\bt\b|exec|yarn|pnpm/.test(String(command || ""))) return runs;
1117
+ if (depth > 4 || !/jest|vitest|tsc|test|run|\bt\b|exec|yarn|pnpm/.test(String(command || ""))) return runs;
1114
1118
  let dir = path.resolve(String(cwd || process.cwd()));
1115
1119
  const { commands, nested } = shellCommands(command);
1116
1120
  const scripts = [...nested];
@@ -1204,6 +1208,9 @@ function testRunWorkers(run, cpus) {
1204
1208
  if (args.some((a) => NOT_A_RUN_FLAGS.has(a)) || (runner === "vitest" && ["list", "init"].includes(args[0]))) {
1205
1209
  return { notARun: true };
1206
1210
  }
1211
+ if (runner === "tsc") {
1212
+ return args.some((a) => TSC_NOT_A_RUN_FLAGS.has(a)) ? { notARun: true } : { workers: 1, source: "tsc" };
1213
+ }
1207
1214
  if (runner === "jest") {
1208
1215
  if (args.includes("--runInBand") || args.includes("-i") || args.includes("--run-in-band")) return { workers: 1, source: "--runInBand" };
1209
1216
  const value = flagValue(args, ["--maxWorkers", "--max-workers", "-w"]);
@@ -1333,8 +1340,8 @@ function testRunDenial(command, { cwd, session = "", probe = systemProbe, limits
1333
1340
  return {
1334
1341
  reason: "busy",
1335
1342
  message:
1336
- `⛔ Test-run guard: another test run is already going on this machine (pid ${busy.pid}, running ${busy.etime}${where ? `, in ${where}` : ""}): ${String(busy.command).slice(0, 140)}\n` +
1337
- `Only one test run at a time, so parallel conversations cannot run the machine out of memory. Wait for it to finish, then run yours. If it is your own leftover run, stop it first.`,
1343
+ `⛔ Test-run guard: another test run or type check is already going on this machine (pid ${busy.pid}, running ${busy.etime}${where ? `, in ${where}` : ""}): ${String(busy.command).slice(0, 140)}\n` +
1344
+ `Only one test run or type check at a time, so parallel conversations cannot run the machine out of memory. Wait for it to finish, then run yours. If it is your own leftover run, stop it first.`,
1338
1345
  };
1339
1346
  }
1340
1347
  const claim = readClaim(limits.claimFile);
@@ -1342,7 +1349,7 @@ function testRunDenial(command, { cwd, session = "", probe = systemProbe, limits
1342
1349
  return {
1343
1350
  reason: "busy",
1344
1351
  message:
1345
- `⛔ Test-run guard: another conversation started a test run ${Math.round((probe.now() - claim.at) / 1000)} s ago${claim.cwd ? ` in ${home(claim.cwd)}` : ""}. Only one runs at a time; wait for it to finish, then run yours.`,
1352
+ `⛔ Test-run guard: another conversation started a test run or type check ${Math.round((probe.now() - claim.at) / 1000)} s ago${claim.cwd ? ` in ${home(claim.cwd)}` : ""}. Only one runs at a time; wait for it to finish, then run yours.`,
1346
1353
  };
1347
1354
  }
1348
1355
  const free = probe.freePct();
@@ -1350,7 +1357,7 @@ function testRunDenial(command, { cwd, session = "", probe = systemProbe, limits
1350
1357
  return {
1351
1358
  reason: "memory",
1352
1359
  message:
1353
- `⛔ Test-run guard: free memory is ${free}%, under the ${limits.minFreePct}% a test run needs. Wait a few minutes and try again; do not raise the worker count.` +
1360
+ `⛔ Test-run guard: free memory is ${free}%, under the ${limits.minFreePct}% a test run or type check needs. Wait a few minutes and try again; do not raise the worker count.` +
1354
1361
  (process.platform === "darwin" ? " See what is using memory: open-claudia tool run mac-health memory-guard-status" : ""),
1355
1362
  };
1356
1363
  }