@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/CHANGELOG.md +27 -0
- package/Dockerfile +2 -0
- package/bin/cli.js +17 -0
- package/bin/handoff.js +12 -3
- package/bin/views.js +255 -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/telegram/adapter.js +2 -1
- package/core/actions.js +8 -0
- package/core/lessons.js +2 -1
- package/core/loopback.js +118 -5
- package/core/migration-backup.js +6 -2
- package/core/model-prices.js +3 -0
- package/core/pack-review.js +77 -10
- package/core/providers/claude.js +3 -0
- 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 +2 -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/docker-entrypoint.sh +10 -0
- package/package.json +3 -3
- package/test-provider-claude.js +2 -0
- package/test-provider-language.js +5 -1
- package/test-provider-migration-backup.js +13 -0
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
|
-
|
|
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
|
-
|
|
311
|
-
|
|
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(
|
|
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
|
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.
|
package/core/model-prices.js
CHANGED
|
@@ -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],
|
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/providers/claude.js
CHANGED
|
@@ -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",
|
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
|
}
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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);
|
package/core/system-prompt.js
CHANGED
|
@@ -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)
|
package/core/thread-handoff.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
151
|
+
let targetThreadId = String(payload.targetThreadId || "").trim();
|
|
139
152
|
const targetChatId = String(payload.targetChatId || "").trim() || sourceChatId;
|
|
140
|
-
|
|
141
|
-
if (
|
|
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
|
-
|
|
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
|
|
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
|
};
|
package/core/tool-guard.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|