@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.
Files changed (46) hide show
  1. package/.env.example +10 -0
  2. package/CHANGELOG.md +53 -0
  3. package/README.md +4 -0
  4. package/bin/cli.js +1 -1
  5. package/bin/task.js +51 -0
  6. package/bot.js +26 -0
  7. package/channels/kazee/adapter.js +130 -21
  8. package/channels/telegram/adapter.js +4 -2
  9. package/core/actions.js +8 -0
  10. package/core/config.js +22 -1
  11. package/core/cost-report.js +3 -0
  12. package/core/cron-schedule.js +59 -0
  13. package/core/dream.js +141 -34
  14. package/core/guardrail.js +5 -0
  15. package/core/handlers.js +78 -17
  16. package/core/lessons.js +11 -5
  17. package/core/limit-pickup.js +313 -0
  18. package/core/loopback.js +29 -0
  19. package/core/providers/claude-events.js +79 -0
  20. package/core/providers/codex.js +48 -0
  21. package/core/providers/events.js +4 -1
  22. package/core/relationship.js +1 -1
  23. package/core/router.js +16 -1
  24. package/core/run-context.js +3 -0
  25. package/core/runner.js +440 -14
  26. package/core/safe-filename.js +47 -0
  27. package/core/scheduler.js +170 -38
  28. package/core/state.js +91 -0
  29. package/core/system-prompt.js +89 -16
  30. package/core/tasks.js +208 -6
  31. package/core/turn-journal.js +508 -0
  32. package/core/usage-limits.js +321 -0
  33. package/core/utility-agent.js +17 -1
  34. package/package.json +4 -4
  35. package/test-fixtures/fake-agent-cli.js +26 -2
  36. package/test-guardrail.js +19 -0
  37. package/test-kazee-context-media.js +1 -1
  38. package/test-provider-compaction.js +66 -1
  39. package/test-provider-dream.js +3 -1
  40. package/test-provider-events.js +106 -0
  41. package/test-provider-language.js +16 -1
  42. package/test-provider-pack-review.js +3 -1
  43. package/test-provider-scheduler.js +72 -1
  44. package/test-thread-lanes.js +6 -1
  45. package/test-usage-accounting.js +4 -0
  46. 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
- const scheduleCron = dependencies.scheduleCron || ((expression, callback) => cron.schedule(expression, callback, { noOverlap: true }));
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.newSessionId;
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 unscheduleJob(job.id);
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 canonicalUserId = job.canonicalUserId || canonicalForChannel(adapter.type, job.channelId);
726
- const userId = String(job.userId || (adapter.type === "telegram" ? job.channelId : canonicalUserId || job.channelId || ""));
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 { result = await runInProvider(prompt, runContext.cwd, null, { ...runOptions, runContext }); }
841
- catch (error) {
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
- status: "dead-letter",
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,
@@ -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.delete(coreInstructionsByIdentity.keys().next().value);
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
- function buildSpeakerContextBlock() {
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 roster = people.roster();
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 full task tree is large and rides the (uncached) tail of every
517
- // prompt, so inject it only once per session: on the first turn after a
518
- // bot restart, a new conversation, or a compaction (the captured run identity
519
- // changes in all three cases). Later turns get a one-line count; the
520
- // agent runs `open-claudia task list` when it needs detail.
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 tree = tasksStore.formatForInjection(adapter.id, channelId, { showIds: true });
558
- lines.push("", "## Pending tasks", "Ranked by recent activity (`updatedAt`). The most-recently-worked items show in full; colder ones are title-only; stale ones (untouched >2w) are batched at the bottom — they're likely finished or abandoned, so close them with `open-claudia task done <id>` or reopen by working them. Mark a task in_progress when you actually start it and done when finished, so this ranking stays honest. If something in our conversation shows a task is already finished, proactively offer to close it. Shown once per session — run `open-claudia task list` for the full tree.", "", tree);
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 (channel, session, pack-version) — once
572
- // it's in the conversation the model keeps it until compaction.
573
- const packsInjectedFor = new Map(); // `${adapterId}:${conversationKey}:${dir}` -> `${sessionId}:${updated}`
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.updated}`;
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 (channel, session, version). A
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,