@inetafrica/open-claudia 3.5.16 → 3.5.18
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/.env.example +10 -0
- package/CHANGELOG.md +53 -0
- package/README.md +4 -0
- package/bin/cli.js +1 -1
- package/bin/task.js +51 -0
- package/bot.js +26 -0
- package/channels/kazee/adapter.js +130 -21
- package/channels/telegram/adapter.js +4 -2
- package/core/actions.js +8 -0
- package/core/config.js +22 -1
- package/core/cost-report.js +3 -0
- package/core/cron-schedule.js +59 -0
- package/core/dream.js +141 -34
- package/core/guardrail.js +5 -0
- package/core/handlers.js +78 -17
- package/core/lessons.js +11 -5
- package/core/limit-pickup.js +313 -0
- package/core/loopback.js +29 -0
- package/core/providers/claude-events.js +79 -0
- package/core/providers/codex.js +48 -0
- package/core/providers/events.js +4 -1
- package/core/relationship.js +1 -1
- package/core/router.js +16 -1
- package/core/run-context.js +3 -0
- package/core/runner.js +440 -14
- package/core/safe-filename.js +47 -0
- package/core/scheduler.js +170 -38
- package/core/state.js +91 -0
- package/core/system-prompt.js +89 -16
- package/core/tasks.js +208 -6
- package/core/turn-journal.js +508 -0
- package/core/usage-limits.js +321 -0
- package/core/utility-agent.js +17 -1
- package/package.json +4 -4
- package/test-fixtures/fake-agent-cli.js +26 -2
- package/test-guardrail.js +19 -0
- package/test-kazee-context-media.js +1 -1
- package/test-provider-compaction.js +66 -1
- package/test-provider-dream.js +3 -1
- package/test-provider-events.js +106 -0
- package/test-provider-language.js +16 -1
- package/test-provider-pack-review.js +3 -1
- package/test-provider-scheduler.js +72 -1
- package/test-thread-lanes.js +6 -1
- package/test-usage-accounting.js +4 -0
- package/web.js +104 -51
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// Sender-chosen file names (Kazee and Telegram documents) become paths under
|
|
4
|
+
// FILES_DIR. A name like "../auth.json" must never climb out of that folder,
|
|
5
|
+
// yet "Invoice March (final).pdf" or "ripoti ya mauzo.xlsx" should stay as the
|
|
6
|
+
// sender wrote them. Anything unusable falls back to the adapter's generated
|
|
7
|
+
// name.
|
|
8
|
+
|
|
9
|
+
const path = require("path");
|
|
10
|
+
|
|
11
|
+
const MAX_BYTES = 200; // well under the usual 255-byte filesystem limit
|
|
12
|
+
const MAX_EXT_BYTES = 20;
|
|
13
|
+
|
|
14
|
+
function capBytes(text, max) {
|
|
15
|
+
let out = "";
|
|
16
|
+
for (const ch of text) {
|
|
17
|
+
if (Buffer.byteLength(out + ch) > max) break;
|
|
18
|
+
out += ch;
|
|
19
|
+
}
|
|
20
|
+
return out;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// The cleaned name, or `fallback` when nothing safe is left.
|
|
24
|
+
function safeFileName(name, fallback) {
|
|
25
|
+
let s = String(name ?? "").split(/[\\/]/).pop();
|
|
26
|
+
s = s.replace(/[\u0000-\u001f\u007f-\u009f]/g, "");
|
|
27
|
+
s = s.replace(/[^\p{L}\p{M}\p{N} ._\-()]/gu, "_").trim();
|
|
28
|
+
if (!s || s === "." || s === ".." || s.startsWith(".")) return fallback;
|
|
29
|
+
if (Buffer.byteLength(s) > MAX_BYTES) {
|
|
30
|
+
let ext = path.extname(s);
|
|
31
|
+
if (Buffer.byteLength(ext) > MAX_EXT_BYTES) ext = "";
|
|
32
|
+
s = capBytes(s.slice(0, s.length - ext.length), MAX_BYTES - Buffer.byteLength(ext)).trim() + ext;
|
|
33
|
+
if (!s || s.startsWith(".")) return fallback;
|
|
34
|
+
}
|
|
35
|
+
return s;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// baseDir joined with the cleaned name; the generated fallback whenever the
|
|
39
|
+
// result would not sit directly inside baseDir.
|
|
40
|
+
function safeJoin(baseDir, name, fallback) {
|
|
41
|
+
const root = path.resolve(baseDir);
|
|
42
|
+
const result = path.resolve(root, safeFileName(name, fallback));
|
|
43
|
+
if (!result.startsWith(root + path.sep) || path.dirname(result) !== root) return path.join(root, fallback);
|
|
44
|
+
return result;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
module.exports = { safeFileName, safeJoin };
|
package/core/scheduler.js
CHANGED
|
@@ -15,11 +15,13 @@ const crypto = require("crypto");
|
|
|
15
15
|
const fs = require("fs");
|
|
16
16
|
const path = require("path");
|
|
17
17
|
const cron = require("node-cron");
|
|
18
|
+
const { scheduleTolerant } = require("./cron-schedule");
|
|
18
19
|
const { spawn } = require("child_process");
|
|
19
20
|
const CONFIG_DIR = require("../config-dir");
|
|
20
21
|
|
|
21
22
|
const {
|
|
22
23
|
CRONS_FILE,
|
|
24
|
+
CRON_LATE_TOLERANCE_MS,
|
|
23
25
|
WORKSPACE,
|
|
24
26
|
config,
|
|
25
27
|
} = require("./config");
|
|
@@ -31,6 +33,7 @@ const {
|
|
|
31
33
|
getProjectKey,
|
|
32
34
|
getProviderSession,
|
|
33
35
|
getProviderSettings,
|
|
36
|
+
getProjectSessions,
|
|
34
37
|
getUserState,
|
|
35
38
|
peekLane,
|
|
36
39
|
laneBusy,
|
|
@@ -186,6 +189,16 @@ function deliveryPending(job) {
|
|
|
186
189
|
&& ["deferred", "failed", "running"].includes(job.status);
|
|
187
190
|
}
|
|
188
191
|
|
|
192
|
+
// The resume placeholder (see PLACEHOLDER_REPLY in core/runner): a scheduled
|
|
193
|
+
// turn that answers only this did nothing.
|
|
194
|
+
const PLACEHOLDER_REPLY = /^no response requested\.?$/i;
|
|
195
|
+
const PLACEHOLDER_NUDGE = "Act on the prompt above now. Your first output must be a tool call or a real reply.";
|
|
196
|
+
function isPlaceholderResult(result) {
|
|
197
|
+
if (result?.delivery?.suppressed === "placeholder") return true;
|
|
198
|
+
const text = String(result?.finalAnswerText || result?.text || "").trim();
|
|
199
|
+
return PLACEHOLDER_REPLY.test(text);
|
|
200
|
+
}
|
|
201
|
+
|
|
189
202
|
function providerIsRunnable(status) {
|
|
190
203
|
if (!status || status.availability?.state !== "available") return false;
|
|
191
204
|
if (status.compatibility?.state === "incompatible") return false;
|
|
@@ -210,11 +223,16 @@ function createSchedulerService(dependencies = {}) {
|
|
|
210
223
|
const now = dependencies.now || Date.now;
|
|
211
224
|
const setTimer = dependencies.setTimer || setTimeout;
|
|
212
225
|
const clearTimer = dependencies.clearTimer || clearTimeout;
|
|
213
|
-
|
|
226
|
+
// A late heartbeat must not cost the occurrence (core/cron-schedule).
|
|
227
|
+
const cronLateToleranceMs = Number.isFinite(dependencies.cronLateToleranceMs) ? dependencies.cronLateToleranceMs : CRON_LATE_TOLERANCE_MS;
|
|
228
|
+
const scheduleCron = dependencies.scheduleCron || ((expression, callback) => scheduleTolerant(expression, callback, {
|
|
229
|
+
cron: dependencies.cronLib || cron, noOverlap: true, toleranceMs: cronLateToleranceMs, label: `cron ${expression}`,
|
|
230
|
+
}));
|
|
214
231
|
const validateCron = dependencies.validateCron || cron.validate;
|
|
215
232
|
const runInChat = dependencies.runInChat || defaultRunInChat;
|
|
216
233
|
const stateForUser = dependencies.getUserState || getUserState;
|
|
217
234
|
const send = dependencies.send || defaultSend;
|
|
235
|
+
const ownerTargetFor = dependencies.ownerTarget || (() => require("./relationship").ownerTarget());
|
|
218
236
|
const providerStatus = dependencies.providerStatus || defaultProviderStatus;
|
|
219
237
|
const fallbackProviders = parseFallbacks(
|
|
220
238
|
dependencies.fallbackProviders === undefined ? config.PROVIDER_FALLBACKS : dependencies.fallbackProviders,
|
|
@@ -230,6 +248,7 @@ function createSchedulerService(dependencies = {}) {
|
|
|
230
248
|
: dependencies.legacyCronsFile;
|
|
231
249
|
const runInProvider = dependencies.runAgent || ((...args) => require("./runner").runAgent(...args));
|
|
232
250
|
const admit = dependencies.admitRunContext || ((...args) => require("./runner").admitRunContext(...args));
|
|
251
|
+
const projectSessions = dependencies.getProjectSessions || getProjectSessions;
|
|
233
252
|
const timers = new Map();
|
|
234
253
|
const checksInFlight = new Set();
|
|
235
254
|
const firesInFlight = new Set();
|
|
@@ -318,6 +337,26 @@ function createSchedulerService(dependencies = {}) {
|
|
|
318
337
|
return getProviderSession(state, job.provider, roomKey) || null;
|
|
319
338
|
}
|
|
320
339
|
|
|
340
|
+
// previous session -> the session it was compacted into, from the
|
|
341
|
+
// conversation's session history (each compaction's lineage is kept on the
|
|
342
|
+
// new session's record). The completed-compactions index holds only the
|
|
343
|
+
// last 20, under a day of token-diet compactions across every thread, so a
|
|
344
|
+
// job made before them lost its way: 7 Oct the PBX thread's wakeup and
|
|
345
|
+
// watch resumed a 790k conversation compacted that morning ($86).
|
|
346
|
+
function historySuccessors(state, job) {
|
|
347
|
+
const successors = new Map();
|
|
348
|
+
let records = [];
|
|
349
|
+
try { records = projectSessions(state?.userId || job.canonicalUserId, job.project, job.provider) || []; }
|
|
350
|
+
catch (_) { records = []; }
|
|
351
|
+
// Newest first: if a session was ever compacted twice, the latest wins.
|
|
352
|
+
for (const record of records) {
|
|
353
|
+
const lineage = record?.lineage;
|
|
354
|
+
if (lineage?.type !== "compaction" || !lineage.previousSessionId || !lineage.newSessionId) continue;
|
|
355
|
+
if (!successors.has(lineage.previousSessionId)) successors.set(lineage.previousSessionId, lineage.newSessionId);
|
|
356
|
+
}
|
|
357
|
+
return successors;
|
|
358
|
+
}
|
|
359
|
+
|
|
321
360
|
function resolveCompactionLineage(state, job) {
|
|
322
361
|
let sessionId = job.sessionId || null;
|
|
323
362
|
// A thread job never resumes its room's MAIN session: a job made in a
|
|
@@ -330,6 +369,7 @@ function createSchedulerService(dependencies = {}) {
|
|
|
330
369
|
sessionId = own && own !== mainSession ? own : null;
|
|
331
370
|
}
|
|
332
371
|
if (!sessionId) return { sessionId: null, attachSession: true };
|
|
372
|
+
let recorded = null;
|
|
333
373
|
const seen = new Set();
|
|
334
374
|
while (sessionId && !seen.has(sessionId)) {
|
|
335
375
|
seen.add(sessionId);
|
|
@@ -338,9 +378,9 @@ function createSchedulerService(dependencies = {}) {
|
|
|
338
378
|
&& entry?.project === job.project
|
|
339
379
|
&& entry?.previousSessionId === sessionId
|
|
340
380
|
&& entry?.newSessionId
|
|
341
|
-
));
|
|
381
|
+
))?.newSessionId || (recorded ||= historySuccessors(state, job)).get(sessionId);
|
|
342
382
|
if (!successor) break;
|
|
343
|
-
sessionId = successor
|
|
383
|
+
sessionId = successor;
|
|
344
384
|
}
|
|
345
385
|
const live = getProviderSession(state, job.provider, job.project);
|
|
346
386
|
return {
|
|
@@ -521,12 +561,105 @@ function createSchedulerService(dependencies = {}) {
|
|
|
521
561
|
return { ok: false, status: "failed", error: failure };
|
|
522
562
|
}
|
|
523
563
|
|
|
564
|
+
// The chat scope a job runs (and reports) in: its room, its thread, its
|
|
565
|
+
// creator.
|
|
566
|
+
function jobChat(job, adapter) {
|
|
567
|
+
const canonicalUserId = job.canonicalUserId || canonicalForChannel(adapter.type, job.channelId);
|
|
568
|
+
const userId = String(job.userId || (adapter.type === "telegram" ? job.channelId : canonicalUserId || job.channelId || ""));
|
|
569
|
+
return {
|
|
570
|
+
adapter,
|
|
571
|
+
channelId: String(job.channelId),
|
|
572
|
+
canonicalUserId,
|
|
573
|
+
userId,
|
|
574
|
+
transport: adapter.type,
|
|
575
|
+
raw: null,
|
|
576
|
+
// Key the resumed conversation by the job's saved thread, not the live
|
|
577
|
+
// shared session. Matches captured project.name (projectDescriptor(job).name)
|
|
578
|
+
// so sameCapturedProject / getProjectKey resolve the right thread mid-run.
|
|
579
|
+
conversationKey: projectDescriptor(job).name,
|
|
580
|
+
// A job made in a chat thread answers in that thread: its notice, its
|
|
581
|
+
// typing indicator and its reply all go back into the thread.
|
|
582
|
+
threadId: jobThreadId(job),
|
|
583
|
+
// …but the thread is not the room. A job belongs to the room it was
|
|
584
|
+
// created in and must answer under THAT room's provider; without this the
|
|
585
|
+
// room overlay can't resolve and the run silently falls back to the
|
|
586
|
+
// creator's personal default — which is how a Codex room kept firing
|
|
587
|
+
// wakeups on Claude.
|
|
588
|
+
roomKey: conversationKeyFor(adapter.type, job.channelId),
|
|
589
|
+
// Rides only on triage-approval wakeups; the enforcer's reply vet honours
|
|
590
|
+
// the owner's one-off "handle this request" grant through it.
|
|
591
|
+
triageApprovalId: job.triageApprovalId || "",
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// A job that will not run (a one-shot dead-lettered, or a recurring
|
|
596
|
+
// occurrence that failed for good) says so once, in its own conversation, or
|
|
597
|
+
// to the owner when that channel is gone. Until now only `cron-list` showed
|
|
598
|
+
// "DEAD: will not fire".
|
|
599
|
+
function jobDeathNotice(job, failure, recurring) {
|
|
600
|
+
const label = String(job.label || job.prompt || job.id).replace(/\s+/g, " ").slice(0, 60);
|
|
601
|
+
const code = failure?.code || "SCHEDULED_RUN_FAILED";
|
|
602
|
+
const kind = job.check ? "Watch" : job.kind === "wakeup" ? "Wake-up" : "Scheduled job";
|
|
603
|
+
if (recurring) {
|
|
604
|
+
return `⚠️ ${kind} "${label}" failed ${job.attemptCount || 1} times this time (${code}); that run is skipped. It stays scheduled and runs again at its next time (open-claudia cron-list).`;
|
|
605
|
+
}
|
|
606
|
+
const rearm = job.check
|
|
607
|
+
? "open-claudia watch-add \"<check>\" \"<prompt>\""
|
|
608
|
+
: job.kind === "wakeup" ? "open-claudia schedule-wakeup <when> \"<prompt>\"" : "open-claudia cron-add \"<schedule>\" \"<prompt>\"";
|
|
609
|
+
return `⚠️ ${kind} "${label}" gave up (${code}) and will not fire. To re-arm it: ${rearm} (${job.id}).`;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function notifyJobDeath(job, failure, recurring) {
|
|
613
|
+
const text = jobDeathNotice(job, failure, recurring);
|
|
614
|
+
console.warn(`[scheduler] ${job.id}: ${text}`);
|
|
615
|
+
const adapter = resolveAdapter(job);
|
|
616
|
+
const delivery = adapter && job.channelId
|
|
617
|
+
? Promise.resolve(runInChat(jobChat(job, adapter), () => send(text)))
|
|
618
|
+
: Promise.resolve(ownerTargetFor()).then((target) => {
|
|
619
|
+
const ownerAdapter = target && (adapters.find((a) => a.id === target.adapter) || adapters.find((a) => a.type === target.adapter));
|
|
620
|
+
if (!ownerAdapter) throw new Error("no owner channel");
|
|
621
|
+
return ownerAdapter.send(target.channelId, text);
|
|
622
|
+
});
|
|
623
|
+
return delivery.catch((error) => console.warn(`[scheduler] ${job.id}: death notice not delivered: ${error?.message || error}`));
|
|
624
|
+
}
|
|
625
|
+
|
|
524
626
|
function markFailure(job, failure, options = {}) {
|
|
525
627
|
const latest = jobsStore.get(job.id) || job;
|
|
526
628
|
const attemptCount = options.attemptAlreadyCounted
|
|
527
629
|
? Math.max(1, latest.attemptCount || 0)
|
|
528
630
|
: (latest.attemptCount || 0) + 1;
|
|
529
631
|
const dead = attemptCount >= (latest.maxAttempts || 1);
|
|
632
|
+
// A recurring job keeps its schedule: the failed occurrence is recorded and
|
|
633
|
+
// skipped, the next one starts fresh. Only a one-shot dead-letters.
|
|
634
|
+
if (dead && !isOneShot(latest)) {
|
|
635
|
+
const failed = stableFailure(failure);
|
|
636
|
+
const advance = {
|
|
637
|
+
status: "pending",
|
|
638
|
+
disabled: false,
|
|
639
|
+
occurrenceId: occurrenceIdFactory(latest),
|
|
640
|
+
attemptCount: 0,
|
|
641
|
+
busyDeferralCount: 0,
|
|
642
|
+
nextAttemptAt: null,
|
|
643
|
+
lastFireAt: now(),
|
|
644
|
+
lastFireOk: false,
|
|
645
|
+
lastError: failed,
|
|
646
|
+
failedOccurrences: (Number(latest.failedOccurrences) || 0) + 1,
|
|
647
|
+
lastFailedOccurrenceId: latest.occurrenceId || null,
|
|
648
|
+
watchTriggeredOccurrenceId: null,
|
|
649
|
+
watchTriggeredAt: null,
|
|
650
|
+
watchExpired: false,
|
|
651
|
+
watchCheckTimingOut: false,
|
|
652
|
+
watchExtraHits: 0,
|
|
653
|
+
watchLastHitAt: null,
|
|
654
|
+
watchLastClearAt: null,
|
|
655
|
+
advisoryOccurrenceId: null,
|
|
656
|
+
};
|
|
657
|
+
const advanced = jobsStore.update(job.id, advance) || { ...latest, ...advance };
|
|
658
|
+
unscheduleJob(job.id);
|
|
659
|
+
scheduleJob(advanced);
|
|
660
|
+
notifyJobDeath({ ...latest, attemptCount }, failed, true);
|
|
661
|
+
return { ok: false, status: "failed", occurrenceSkipped: true, job: advanced, error: failed };
|
|
662
|
+
}
|
|
530
663
|
const patch = {
|
|
531
664
|
status: dead ? "dead-letter" : "failed",
|
|
532
665
|
disabled: dead,
|
|
@@ -538,7 +671,10 @@ function createSchedulerService(dependencies = {}) {
|
|
|
538
671
|
};
|
|
539
672
|
const updated = jobsStore.update(job.id, patch) || { ...latest, ...patch };
|
|
540
673
|
if (!dead) armRetry(updated);
|
|
541
|
-
else
|
|
674
|
+
else {
|
|
675
|
+
unscheduleJob(job.id);
|
|
676
|
+
notifyJobDeath(updated, updated.lastError, false);
|
|
677
|
+
}
|
|
542
678
|
return { ok: false, status: updated.status, job: updated, error: updated.lastError };
|
|
543
679
|
}
|
|
544
680
|
|
|
@@ -581,6 +717,14 @@ function createSchedulerService(dependencies = {}) {
|
|
|
581
717
|
lastProvider: target.provider,
|
|
582
718
|
fallbackFromProvider: target.fallbackFrom,
|
|
583
719
|
};
|
|
720
|
+
// A recurring job keeps its own conversation: remember the session this
|
|
721
|
+
// run resumed and ended on (lineage or a compaction may have moved it), so
|
|
722
|
+
// the next run starts there even after the lineage records age out. Only
|
|
723
|
+
// for a run that resumed the job's conversation under its own provider.
|
|
724
|
+
const ranOn = typeof result?.sessionId === "string" && result.sessionId ? result.sessionId : null;
|
|
725
|
+
if (!isOneShot(job) && ranOn && ranOn !== job.sessionId && target.sessionId && !target.fresh && target.provider === job.provider) {
|
|
726
|
+
successPatch.sessionId = ranOn;
|
|
727
|
+
}
|
|
584
728
|
let marked;
|
|
585
729
|
try { marked = jobsStore.update(job.id, successPatch); }
|
|
586
730
|
catch (error) { return { ok: false, status: "failed", error: stableFailure(error), result }; }
|
|
@@ -722,32 +866,8 @@ function createSchedulerService(dependencies = {}) {
|
|
|
722
866
|
try { return markFailure(job, { code: "SCHEDULED_ADAPTER_UNAVAILABLE", message: "Saved channel adapter is unavailable" }); }
|
|
723
867
|
catch (error) { return keepRetrying(job, error); }
|
|
724
868
|
}
|
|
725
|
-
const
|
|
726
|
-
const
|
|
727
|
-
const chat = {
|
|
728
|
-
adapter,
|
|
729
|
-
channelId: String(job.channelId),
|
|
730
|
-
canonicalUserId,
|
|
731
|
-
userId,
|
|
732
|
-
transport: adapter.type,
|
|
733
|
-
raw: null,
|
|
734
|
-
// Key the resumed conversation by the job's saved thread, not the live
|
|
735
|
-
// shared session. Matches captured project.name (projectDescriptor(job).name)
|
|
736
|
-
// so sameCapturedProject / getProjectKey resolve the right thread mid-run.
|
|
737
|
-
conversationKey: projectDescriptor(job).name,
|
|
738
|
-
// A job made in a chat thread answers in that thread: its notice, its
|
|
739
|
-
// typing indicator and its reply all go back into the thread.
|
|
740
|
-
threadId: jobThreadId(job),
|
|
741
|
-
// …but the thread is not the room. A job belongs to the room it was
|
|
742
|
-
// created in and must answer under THAT room's provider; without this the
|
|
743
|
-
// room overlay can't resolve and the run silently falls back to the
|
|
744
|
-
// creator's personal default — which is how a Codex room kept firing
|
|
745
|
-
// wakeups on Claude.
|
|
746
|
-
roomKey: conversationKeyFor(adapter.type, job.channelId),
|
|
747
|
-
// Rides only on triage-approval wakeups; the enforcer's reply vet honours
|
|
748
|
-
// the owner's one-off "handle this request" grant through it.
|
|
749
|
-
triageApprovalId: job.triageApprovalId || "",
|
|
750
|
-
};
|
|
869
|
+
const chat = jobChat(job, adapter);
|
|
870
|
+
const { canonicalUserId } = chat;
|
|
751
871
|
|
|
752
872
|
return runInChat(chat, async () => {
|
|
753
873
|
noteLegacyCreatorOnce(job);
|
|
@@ -837,8 +957,23 @@ function createSchedulerService(dependencies = {}) {
|
|
|
837
957
|
} catch (_) { /* advisory only */ }
|
|
838
958
|
}
|
|
839
959
|
let result;
|
|
840
|
-
try {
|
|
841
|
-
|
|
960
|
+
try {
|
|
961
|
+
result = await runInProvider(prompt, runContext.cwd, null, { ...runOptions, runContext });
|
|
962
|
+
// A scheduled turn whose whole reply is the resume placeholder did
|
|
963
|
+
// nothing (the runner never posts it). Nudge it once, in the same
|
|
964
|
+
// session; never more than once.
|
|
965
|
+
if (terminalRunSucceeded(result) && isPlaceholderResult(result)) {
|
|
966
|
+
const sessionId = result.sessionId || runContext.sessionId || null;
|
|
967
|
+
console.warn(`[scheduler] ${job.id}: the scheduled turn answered "No response requested." — re-running once with a nudge`);
|
|
968
|
+
const retryPrompt = sessionId ? PLACEHOLDER_NUDGE : `${prompt}\n\n${PLACEHOLDER_NUDGE}`;
|
|
969
|
+
const retryOptions = { ...runOptions, fresh: !sessionId, resumeSessionId: sessionId };
|
|
970
|
+
const retryContext = admit(retryPrompt, project.dir, null, retryOptions);
|
|
971
|
+
result = await runInProvider(retryPrompt, retryContext.cwd, null, { ...retryOptions, runContext: retryContext });
|
|
972
|
+
if (terminalRunSucceeded(result) && isPlaceholderResult(result)) {
|
|
973
|
+
console.warn(`[scheduler] ${job.id}: the nudged re-run answered "No response requested." too — not retrying again`);
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
} catch (error) {
|
|
842
977
|
try { return markFailure(running, error, { attemptAlreadyCounted: true }); }
|
|
843
978
|
catch (persistError) { return keepRetrying(running, persistError); }
|
|
844
979
|
}
|
|
@@ -920,13 +1055,10 @@ function createSchedulerService(dependencies = {}) {
|
|
|
920
1055
|
const fireAt = Number(job.fireAt || 0);
|
|
921
1056
|
const overdue = now() - fireAt;
|
|
922
1057
|
if (overdue > missedGraceMs) {
|
|
1058
|
+
const lastError = { code: "MISSED_WAKEUP_EXPIRED", message: "Wakeup exceeded the missed-fire grace window" };
|
|
923
1059
|
try {
|
|
924
|
-
jobsStore.update(job.id, {
|
|
925
|
-
|
|
926
|
-
disabled: true,
|
|
927
|
-
nextAttemptAt: null,
|
|
928
|
-
lastError: { code: "MISSED_WAKEUP_EXPIRED", message: "Wakeup exceeded the missed-fire grace window" },
|
|
929
|
-
});
|
|
1060
|
+
jobsStore.update(job.id, { status: "dead-letter", disabled: true, nextAttemptAt: null, lastError });
|
|
1061
|
+
notifyJobDeath(job, lastError, false);
|
|
930
1062
|
} catch (_) {}
|
|
931
1063
|
return false;
|
|
932
1064
|
}
|
package/core/state.js
CHANGED
|
@@ -1671,6 +1671,96 @@ function commitProviderCompaction(state, transaction, options = {}) {
|
|
|
1671
1671
|
}
|
|
1672
1672
|
}
|
|
1673
1673
|
|
|
1674
|
+
// A scheduled job resumes its own conversation: a branch beside its thread's
|
|
1675
|
+
// live session once the thread has moved on. Compacting that branch records
|
|
1676
|
+
// the lineage (on the new session's history record and in the completed-
|
|
1677
|
+
// compactions index) so the job moves on to the new session, but never
|
|
1678
|
+
// touches the thread's live pointer: the thread is not the job's conversation.
|
|
1679
|
+
function commitBranchCompaction(state, transaction, options = {}) {
|
|
1680
|
+
const tx = validateCompactionTransaction(state, transaction);
|
|
1681
|
+
if (state.completedCompactions?.[tx.operationId]) {
|
|
1682
|
+
return {
|
|
1683
|
+
committed: true,
|
|
1684
|
+
duplicate: true,
|
|
1685
|
+
operationId: tx.operationId,
|
|
1686
|
+
sessionId: state.completedCompactions[tx.operationId].newSessionId,
|
|
1687
|
+
};
|
|
1688
|
+
}
|
|
1689
|
+
if (getProviderSession(state, tx.provider, tx.project) === tx.previousSessionId) {
|
|
1690
|
+
throw compactionError("COMPACTION_POINTER_CHANGED", "the branch became the live session before its compaction commit");
|
|
1691
|
+
}
|
|
1692
|
+
const persistState = options.persistState || saveState;
|
|
1693
|
+
const recordHistory = options.recordHistory || recordSession;
|
|
1694
|
+
const nowValue = typeof options.now === "function" ? options.now() : new Date();
|
|
1695
|
+
const now = nowValue instanceof Date ? nowValue : new Date(nowValue);
|
|
1696
|
+
if (!Number.isFinite(now.getTime())) throw compactionError("INVALID_COMPACTION", "compaction clock is invalid");
|
|
1697
|
+
requireCompactionWrite(
|
|
1698
|
+
recordHistory(
|
|
1699
|
+
tx.canonicalUserId,
|
|
1700
|
+
tx.project,
|
|
1701
|
+
tx.provider,
|
|
1702
|
+
tx.newSessionId,
|
|
1703
|
+
tx.title || `Scheduled job conversation, compacted ${now.toLocaleDateString()}`,
|
|
1704
|
+
{
|
|
1705
|
+
strict: true,
|
|
1706
|
+
now: () => now,
|
|
1707
|
+
lineage: {
|
|
1708
|
+
type: "compaction",
|
|
1709
|
+
operationId: tx.operationId,
|
|
1710
|
+
previousSessionId: tx.previousSessionId,
|
|
1711
|
+
newSessionId: tx.newSessionId,
|
|
1712
|
+
branch: true,
|
|
1713
|
+
},
|
|
1714
|
+
},
|
|
1715
|
+
),
|
|
1716
|
+
"branch compaction history",
|
|
1717
|
+
);
|
|
1718
|
+
const newUsageKey = usageSessionKey(tx.project, tx.provider, tx.newSessionId);
|
|
1719
|
+
if (!isRecord(state.usageBySession)) state.usageBySession = {};
|
|
1720
|
+
const previousUsageBySession = state.usageBySession;
|
|
1721
|
+
const previousCompleted = state.completedCompactions;
|
|
1722
|
+
const nextUsage = {
|
|
1723
|
+
...freshUsage(),
|
|
1724
|
+
lastCompactedAt: now.getTime(),
|
|
1725
|
+
compactionCheckpoint: {
|
|
1726
|
+
operationId: tx.operationId,
|
|
1727
|
+
previousSessionId: tx.previousSessionId,
|
|
1728
|
+
newSessionId: tx.newSessionId,
|
|
1729
|
+
at: now.toISOString(),
|
|
1730
|
+
branch: true,
|
|
1731
|
+
},
|
|
1732
|
+
};
|
|
1733
|
+
if (tx.provider === "codex" && isRecord(tx.seedUsage)) {
|
|
1734
|
+
nextUsage.providerUsageSnapshot = {
|
|
1735
|
+
input_tokens: Number(tx.seedUsage.input_tokens) || 0,
|
|
1736
|
+
cached_input_tokens: Number(tx.seedUsage.cached_input_tokens) || 0,
|
|
1737
|
+
output_tokens: Number(tx.seedUsage.output_tokens) || 0,
|
|
1738
|
+
};
|
|
1739
|
+
}
|
|
1740
|
+
state.usageBySession = boundUsageBySession({ ...previousUsageBySession, [newUsageKey]: nextUsage });
|
|
1741
|
+
state.completedCompactions = Object.fromEntries(Object.entries({
|
|
1742
|
+
...cloneRecord(previousCompleted),
|
|
1743
|
+
[tx.operationId]: {
|
|
1744
|
+
project: tx.project,
|
|
1745
|
+
provider: tx.provider,
|
|
1746
|
+
previousSessionId: tx.previousSessionId,
|
|
1747
|
+
newSessionId: tx.newSessionId,
|
|
1748
|
+
committedAt: now.toISOString(),
|
|
1749
|
+
branch: true,
|
|
1750
|
+
},
|
|
1751
|
+
}).slice(-20));
|
|
1752
|
+
try {
|
|
1753
|
+
requireCompactionWrite(persistState({ strict: true }), "branch compaction state");
|
|
1754
|
+
} catch (error) {
|
|
1755
|
+
// The history record stays: the new session IS the branch's compacted
|
|
1756
|
+
// continuation, and the scheduler follows it from there.
|
|
1757
|
+
state.usageBySession = previousUsageBySession;
|
|
1758
|
+
state.completedCompactions = previousCompleted;
|
|
1759
|
+
throw error;
|
|
1760
|
+
}
|
|
1761
|
+
return { committed: true, duplicate: false, branch: true, operationId: tx.operationId, sessionId: tx.newSessionId };
|
|
1762
|
+
}
|
|
1763
|
+
|
|
1674
1764
|
function migrateUserData(fromUserId, toUserId) {
|
|
1675
1765
|
const from = normalizeCanonicalUserId(fromUserId);
|
|
1676
1766
|
const to = normalizeCanonicalUserId(toUserId);
|
|
@@ -1828,6 +1918,7 @@ module.exports = {
|
|
|
1828
1918
|
createSessionCallbackToken,
|
|
1829
1919
|
consumeSessionCallbackToken,
|
|
1830
1920
|
commitProviderCompaction,
|
|
1921
|
+
commitBranchCompaction,
|
|
1831
1922
|
recoverPendingCompaction,
|
|
1832
1923
|
migrateUserData,
|
|
1833
1924
|
linkIdentity,
|
package/core/system-prompt.js
CHANGED
|
@@ -402,13 +402,72 @@ function buildCoreInstructions(opts = {}) {
|
|
|
402
402
|
const core = mandatoryPromptPart("coreInstructions", buildCoreInstructionsUncached);
|
|
403
403
|
if (!core.trim()) throw new PromptConstructionError("coreInstructions", new Error("core instructions were empty"));
|
|
404
404
|
coreInstructionsByIdentity.set(key, core);
|
|
405
|
+
// Which lessons this session's (frozen) core carries, and since when: a
|
|
406
|
+
// lesson learned later reaches the session through newLessonsSection.
|
|
407
|
+
let lessonIds = new Set();
|
|
408
|
+
try { lessonIds = new Set(require("./lessons").injectedLessons().map((lesson) => lesson.id)); } catch (_) { /* no lessons */ }
|
|
409
|
+
coreLessonsByIdentity.set(key, { at: Date.now(), ids: lessonIds, shown: new Set() });
|
|
405
410
|
while (coreInstructionsByIdentity.size > 100) {
|
|
406
|
-
coreInstructionsByIdentity.
|
|
411
|
+
const oldest = coreInstructionsByIdentity.keys().next().value;
|
|
412
|
+
coreInstructionsByIdentity.delete(oldest);
|
|
413
|
+
coreLessonsByIdentity.delete(oldest);
|
|
407
414
|
}
|
|
408
415
|
return core;
|
|
409
416
|
}
|
|
410
417
|
|
|
411
|
-
|
|
418
|
+
// Lessons are part of the core instructions, which stay byte-stable for a
|
|
419
|
+
// session, so a lesson added after the session started never reached it.
|
|
420
|
+
// On the session's next turn, lessons created since its core was built (and
|
|
421
|
+
// not in it) ride the per-turn block once each.
|
|
422
|
+
const coreLessonsByIdentity = new Map(); // captured run identity -> { at, ids, shown }
|
|
423
|
+
|
|
424
|
+
function newLessonsSection(opts = {}) {
|
|
425
|
+
let key;
|
|
426
|
+
try { key = promptDedupeIdentity(opts); } catch (_) { return ""; }
|
|
427
|
+
const base = coreLessonsByIdentity.get(key);
|
|
428
|
+
if (!base) return "";
|
|
429
|
+
let fresh = [];
|
|
430
|
+
try {
|
|
431
|
+
const lessonsLib = require("./lessons");
|
|
432
|
+
fresh = lessonsLib.listLessons()
|
|
433
|
+
.filter((lesson) => !base.ids.has(lesson.id) && !base.shown.has(lesson.id))
|
|
434
|
+
.filter((lesson) => Date.parse(lesson.created || "") >= base.at)
|
|
435
|
+
.reverse(); // oldest first
|
|
436
|
+
const lines = [];
|
|
437
|
+
let used = 0;
|
|
438
|
+
for (const lesson of fresh) {
|
|
439
|
+
const line = lessonsLib.lessonLine(lesson);
|
|
440
|
+
if (used + line.length + 1 > lessonsLib.MAX_LESSONS_CHARS) break;
|
|
441
|
+
lines.push(line);
|
|
442
|
+
used += line.length + 1;
|
|
443
|
+
base.shown.add(lesson.id);
|
|
444
|
+
}
|
|
445
|
+
if (!lines.length) return "";
|
|
446
|
+
return ["## New standing rules since this conversation started:", "Learned after this conversation began; treat them exactly like the Lessons learned in your instructions.", ...lines].join("\n");
|
|
447
|
+
} catch (_) { return ""; }
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// The team roster and the slash-command list hardly change and are a few
|
|
451
|
+
// thousand chars each, so like the task section they ride only the first turn
|
|
452
|
+
// of a session per conversation (restart, new conversation, compaction); the
|
|
453
|
+
// session still holds them afterwards. A build that names neither a session
|
|
454
|
+
// nor a run (or has no provider yet) always includes them.
|
|
455
|
+
const channelExtrasShownFor = new Map(); // `${adapterId}:${conversationKey}` -> captured run identity
|
|
456
|
+
|
|
457
|
+
function channelExtrasDue(adapter, channelId, opts) {
|
|
458
|
+
let identity;
|
|
459
|
+
try {
|
|
460
|
+
const run = promptRunIdentity(opts);
|
|
461
|
+
if (!run.sessionId && !run.runId) return true;
|
|
462
|
+
identity = promptDedupeIdentity(opts);
|
|
463
|
+
} catch (_) { return true; }
|
|
464
|
+
const key = dedupeScope(adapter, channelId, opts);
|
|
465
|
+
if (channelExtrasShownFor.get(key) === identity) return false;
|
|
466
|
+
channelExtrasShownFor.set(key, identity);
|
|
467
|
+
return true;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function buildSpeakerContextBlock(opts = {}) {
|
|
412
471
|
const adapter = currentAdapter();
|
|
413
472
|
const channelId = currentChannelId();
|
|
414
473
|
const channelLabel = adapter
|
|
@@ -471,7 +530,8 @@ function buildSpeakerContextBlock() {
|
|
|
471
530
|
blocks.push("", "## Speaker", "No named speaker profile is available for this turn.");
|
|
472
531
|
}
|
|
473
532
|
|
|
474
|
-
const
|
|
533
|
+
const extrasDue = channelExtrasDue(adapter, channelId, opts);
|
|
534
|
+
const roster = extrasDue ? people.roster() : [];
|
|
475
535
|
if (roster.length) {
|
|
476
536
|
const lines = roster.map((person) => {
|
|
477
537
|
const handles = (person.handles || []).map((handle) => `${handle.adapter}:${handle.channelId}`).join(", ") || "(no handles)";
|
|
@@ -481,7 +541,7 @@ function buildSpeakerContextBlock() {
|
|
|
481
541
|
blocks.push("", "## Team", ...lines);
|
|
482
542
|
}
|
|
483
543
|
|
|
484
|
-
const commands = commandsRegistry.publicCommands();
|
|
544
|
+
const commands = extrasDue ? commandsRegistry.publicCommands() : [];
|
|
485
545
|
if (commands.length) {
|
|
486
546
|
blocks.push(
|
|
487
547
|
"",
|
|
@@ -513,11 +573,12 @@ function buildSpeakerContextBlock() {
|
|
|
513
573
|
// the prompt-cache prefix and re-bills the full history at write price
|
|
514
574
|
// instead of 0.1x read price. Keep buildCoreInstructions() byte-stable
|
|
515
575
|
// within a session; put anything that flips between turns here.
|
|
516
|
-
// The
|
|
517
|
-
//
|
|
518
|
-
//
|
|
519
|
-
//
|
|
520
|
-
//
|
|
576
|
+
// The task section rides the tail of the prompt, so inject it only once per
|
|
577
|
+
// session: on the first turn after a bot restart, a new conversation, or a
|
|
578
|
+
// compaction (the captured run identity changes in all three cases). It shows
|
|
579
|
+
// this conversation's own tasks and one line about the rest of the channel
|
|
580
|
+
// (tasks.formatConversationSection, capped). Later turns get a one-line
|
|
581
|
+
// count; the agent runs `open-claudia task list` when it needs detail.
|
|
521
582
|
const taskTreeInjectedFor = new Map(); // `${adapterId}:${conversationKey}` -> captured run identity
|
|
522
583
|
|
|
523
584
|
function buildDynamicContextBlock(opts = {}) {
|
|
@@ -539,6 +600,8 @@ function buildDynamicContextBlock(opts = {}) {
|
|
|
539
600
|
`- Session: ${identity.sessionId ? "resuming existing conversation" : "new conversation"}`,
|
|
540
601
|
...parallelConversationLines(opts.state || currentState(), opts.runContext),
|
|
541
602
|
];
|
|
603
|
+
const lessonsSince = newLessonsSection({ ...opts, state });
|
|
604
|
+
if (lessonsSince) lines.push("", lessonsSince);
|
|
542
605
|
if (adapter && channelId) {
|
|
543
606
|
// Decision cards this bot asked in this conversation that still wait: it
|
|
544
607
|
// closes the ones answered in words or no longer needed. Never breaks the
|
|
@@ -554,8 +617,8 @@ function buildDynamicContextBlock(opts = {}) {
|
|
|
554
617
|
const key = dedupeScope(adapter, channelId, opts);
|
|
555
618
|
if (taskTreeInjectedFor.get(key) !== dedupeIdentity) {
|
|
556
619
|
taskTreeInjectedFor.set(key, dedupeIdentity);
|
|
557
|
-
const
|
|
558
|
-
lines.push("",
|
|
620
|
+
const threadId = opts.runContext?.threadId || currentThreadId() || null;
|
|
621
|
+
lines.push("", tasksStore.formatConversationSection(adapter.id, channelId, { threadId, showIds: true }));
|
|
559
622
|
} else {
|
|
560
623
|
const inProgress = pending.filter((task) => task.status === "in_progress").length;
|
|
561
624
|
lines.push("", `## Pending tasks: ${pending.length} open${inProgress > 0 ? ` (${inProgress} in progress)` : ""} — run \`open-claudia task list\` for the tree. Keep statuses current as you work.`);
|
|
@@ -568,9 +631,18 @@ function buildDynamicContextBlock(opts = {}) {
|
|
|
568
631
|
// Context-pack router: FTS-match the incoming message against packs and
|
|
569
632
|
// inject hits into the per-turn block. Rides the user message (NOT the
|
|
570
633
|
// system prompt) for the same cache reason as the task tree above. Each
|
|
571
|
-
// pack body is injected once per (
|
|
572
|
-
// it's in the conversation the model keeps it until compaction.
|
|
573
|
-
|
|
634
|
+
// pack body is injected once per (conversation, session, pack content) —
|
|
635
|
+
// once it's in the conversation the model keeps it until compaction. The
|
|
636
|
+
// content is what the block renders (name, description, parent, sections),
|
|
637
|
+
// not `updated`: recordApplied, setParent, setSkill and setKind bump
|
|
638
|
+
// `updated` on every use or dream nesting and used to re-inject the whole
|
|
639
|
+
// pack with nothing new in it.
|
|
640
|
+
const packsInjectedFor = new Map(); // `${adapterId}:${conversationKey}:${dir}` -> `${run identity}:${content hash}`
|
|
641
|
+
|
|
642
|
+
function packContentHash(pack) {
|
|
643
|
+
const material = JSON.stringify([pack.name, pack.description, pack.parent || null, pack.sections || {}]);
|
|
644
|
+
return require("crypto").createHash("sha1").update(material).digest("hex").slice(0, 16);
|
|
645
|
+
}
|
|
574
646
|
const PACK_INJECT_MAX_CHARS = 4000;
|
|
575
647
|
const DEFAULT_MEMORY_RECALL_MAX_CHARS = 9000;
|
|
576
648
|
const EXTRACTIVE_LINES_PER_SECTION = 4;
|
|
@@ -782,10 +854,10 @@ function buildPackBlock(matches, budget, opts = {}) {
|
|
|
782
854
|
if (!pack) continue;
|
|
783
855
|
used.push(m.dir);
|
|
784
856
|
const key = `${dedupeScope(adapter, channelId, opts)}:${m.dir}`;
|
|
785
|
-
const stamp = `${runIdentity}:${pack
|
|
857
|
+
const stamp = `${runIdentity}:${packContentHash(pack)}`;
|
|
786
858
|
const pending = pendingRemindersFor("pack", m.dir);
|
|
787
859
|
const pendingText = pending.length ? renderReminders(pending) : "";
|
|
788
|
-
// Inject a pack's body once per (
|
|
860
|
+
// Inject a pack's body once per (conversation, session, content). A
|
|
789
861
|
// compaction mints a new session id, which changes the stamp and
|
|
790
862
|
// forces a fresh re-injection on the next turn (same mechanism
|
|
791
863
|
// as the task tree), so the pack survives compaction without paying
|
|
@@ -1421,6 +1493,7 @@ module.exports = {
|
|
|
1421
1493
|
PromptConstructionError,
|
|
1422
1494
|
buildCoreInstructions,
|
|
1423
1495
|
buildOptionalRecallContext,
|
|
1496
|
+
buildPackBlock,
|
|
1424
1497
|
formatPackForContext,
|
|
1425
1498
|
buildDynamicContextBlock,
|
|
1426
1499
|
buildExternalModeBlock,
|