@inetafrica/open-claudia 3.5.16 → 3.5.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/core/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");
@@ -186,6 +188,16 @@ function deliveryPending(job) {
186
188
  && ["deferred", "failed", "running"].includes(job.status);
187
189
  }
188
190
 
191
+ // The resume placeholder (see PLACEHOLDER_REPLY in core/runner): a scheduled
192
+ // turn that answers only this did nothing.
193
+ const PLACEHOLDER_REPLY = /^no response requested\.?$/i;
194
+ const PLACEHOLDER_NUDGE = "Act on the prompt above now. Your first output must be a tool call or a real reply.";
195
+ function isPlaceholderResult(result) {
196
+ if (result?.delivery?.suppressed === "placeholder") return true;
197
+ const text = String(result?.finalAnswerText || result?.text || "").trim();
198
+ return PLACEHOLDER_REPLY.test(text);
199
+ }
200
+
189
201
  function providerIsRunnable(status) {
190
202
  if (!status || status.availability?.state !== "available") return false;
191
203
  if (status.compatibility?.state === "incompatible") return false;
@@ -210,11 +222,16 @@ function createSchedulerService(dependencies = {}) {
210
222
  const now = dependencies.now || Date.now;
211
223
  const setTimer = dependencies.setTimer || setTimeout;
212
224
  const clearTimer = dependencies.clearTimer || clearTimeout;
213
- const scheduleCron = dependencies.scheduleCron || ((expression, callback) => cron.schedule(expression, callback, { noOverlap: true }));
225
+ // A late heartbeat must not cost the occurrence (core/cron-schedule).
226
+ const cronLateToleranceMs = Number.isFinite(dependencies.cronLateToleranceMs) ? dependencies.cronLateToleranceMs : CRON_LATE_TOLERANCE_MS;
227
+ const scheduleCron = dependencies.scheduleCron || ((expression, callback) => scheduleTolerant(expression, callback, {
228
+ cron: dependencies.cronLib || cron, noOverlap: true, toleranceMs: cronLateToleranceMs, label: `cron ${expression}`,
229
+ }));
214
230
  const validateCron = dependencies.validateCron || cron.validate;
215
231
  const runInChat = dependencies.runInChat || defaultRunInChat;
216
232
  const stateForUser = dependencies.getUserState || getUserState;
217
233
  const send = dependencies.send || defaultSend;
234
+ const ownerTargetFor = dependencies.ownerTarget || (() => require("./relationship").ownerTarget());
218
235
  const providerStatus = dependencies.providerStatus || defaultProviderStatus;
219
236
  const fallbackProviders = parseFallbacks(
220
237
  dependencies.fallbackProviders === undefined ? config.PROVIDER_FALLBACKS : dependencies.fallbackProviders,
@@ -521,12 +538,105 @@ function createSchedulerService(dependencies = {}) {
521
538
  return { ok: false, status: "failed", error: failure };
522
539
  }
523
540
 
541
+ // The chat scope a job runs (and reports) in: its room, its thread, its
542
+ // creator.
543
+ function jobChat(job, adapter) {
544
+ const canonicalUserId = job.canonicalUserId || canonicalForChannel(adapter.type, job.channelId);
545
+ const userId = String(job.userId || (adapter.type === "telegram" ? job.channelId : canonicalUserId || job.channelId || ""));
546
+ return {
547
+ adapter,
548
+ channelId: String(job.channelId),
549
+ canonicalUserId,
550
+ userId,
551
+ transport: adapter.type,
552
+ raw: null,
553
+ // Key the resumed conversation by the job's saved thread, not the live
554
+ // shared session. Matches captured project.name (projectDescriptor(job).name)
555
+ // so sameCapturedProject / getProjectKey resolve the right thread mid-run.
556
+ conversationKey: projectDescriptor(job).name,
557
+ // A job made in a chat thread answers in that thread: its notice, its
558
+ // typing indicator and its reply all go back into the thread.
559
+ threadId: jobThreadId(job),
560
+ // …but the thread is not the room. A job belongs to the room it was
561
+ // created in and must answer under THAT room's provider; without this the
562
+ // room overlay can't resolve and the run silently falls back to the
563
+ // creator's personal default — which is how a Codex room kept firing
564
+ // wakeups on Claude.
565
+ roomKey: conversationKeyFor(adapter.type, job.channelId),
566
+ // Rides only on triage-approval wakeups; the enforcer's reply vet honours
567
+ // the owner's one-off "handle this request" grant through it.
568
+ triageApprovalId: job.triageApprovalId || "",
569
+ };
570
+ }
571
+
572
+ // A job that will not run (a one-shot dead-lettered, or a recurring
573
+ // occurrence that failed for good) says so once, in its own conversation, or
574
+ // to the owner when that channel is gone. Until now only `cron-list` showed
575
+ // "DEAD: will not fire".
576
+ function jobDeathNotice(job, failure, recurring) {
577
+ const label = String(job.label || job.prompt || job.id).replace(/\s+/g, " ").slice(0, 60);
578
+ const code = failure?.code || "SCHEDULED_RUN_FAILED";
579
+ const kind = job.check ? "Watch" : job.kind === "wakeup" ? "Wake-up" : "Scheduled job";
580
+ if (recurring) {
581
+ 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).`;
582
+ }
583
+ const rearm = job.check
584
+ ? "open-claudia watch-add \"<check>\" \"<prompt>\""
585
+ : job.kind === "wakeup" ? "open-claudia schedule-wakeup <when> \"<prompt>\"" : "open-claudia cron-add \"<schedule>\" \"<prompt>\"";
586
+ return `⚠️ ${kind} "${label}" gave up (${code}) and will not fire. To re-arm it: ${rearm} (${job.id}).`;
587
+ }
588
+
589
+ function notifyJobDeath(job, failure, recurring) {
590
+ const text = jobDeathNotice(job, failure, recurring);
591
+ console.warn(`[scheduler] ${job.id}: ${text}`);
592
+ const adapter = resolveAdapter(job);
593
+ const delivery = adapter && job.channelId
594
+ ? Promise.resolve(runInChat(jobChat(job, adapter), () => send(text)))
595
+ : Promise.resolve(ownerTargetFor()).then((target) => {
596
+ const ownerAdapter = target && (adapters.find((a) => a.id === target.adapter) || adapters.find((a) => a.type === target.adapter));
597
+ if (!ownerAdapter) throw new Error("no owner channel");
598
+ return ownerAdapter.send(target.channelId, text);
599
+ });
600
+ return delivery.catch((error) => console.warn(`[scheduler] ${job.id}: death notice not delivered: ${error?.message || error}`));
601
+ }
602
+
524
603
  function markFailure(job, failure, options = {}) {
525
604
  const latest = jobsStore.get(job.id) || job;
526
605
  const attemptCount = options.attemptAlreadyCounted
527
606
  ? Math.max(1, latest.attemptCount || 0)
528
607
  : (latest.attemptCount || 0) + 1;
529
608
  const dead = attemptCount >= (latest.maxAttempts || 1);
609
+ // A recurring job keeps its schedule: the failed occurrence is recorded and
610
+ // skipped, the next one starts fresh. Only a one-shot dead-letters.
611
+ if (dead && !isOneShot(latest)) {
612
+ const failed = stableFailure(failure);
613
+ const advance = {
614
+ status: "pending",
615
+ disabled: false,
616
+ occurrenceId: occurrenceIdFactory(latest),
617
+ attemptCount: 0,
618
+ busyDeferralCount: 0,
619
+ nextAttemptAt: null,
620
+ lastFireAt: now(),
621
+ lastFireOk: false,
622
+ lastError: failed,
623
+ failedOccurrences: (Number(latest.failedOccurrences) || 0) + 1,
624
+ lastFailedOccurrenceId: latest.occurrenceId || null,
625
+ watchTriggeredOccurrenceId: null,
626
+ watchTriggeredAt: null,
627
+ watchExpired: false,
628
+ watchCheckTimingOut: false,
629
+ watchExtraHits: 0,
630
+ watchLastHitAt: null,
631
+ watchLastClearAt: null,
632
+ advisoryOccurrenceId: null,
633
+ };
634
+ const advanced = jobsStore.update(job.id, advance) || { ...latest, ...advance };
635
+ unscheduleJob(job.id);
636
+ scheduleJob(advanced);
637
+ notifyJobDeath({ ...latest, attemptCount }, failed, true);
638
+ return { ok: false, status: "failed", occurrenceSkipped: true, job: advanced, error: failed };
639
+ }
530
640
  const patch = {
531
641
  status: dead ? "dead-letter" : "failed",
532
642
  disabled: dead,
@@ -538,7 +648,10 @@ function createSchedulerService(dependencies = {}) {
538
648
  };
539
649
  const updated = jobsStore.update(job.id, patch) || { ...latest, ...patch };
540
650
  if (!dead) armRetry(updated);
541
- else unscheduleJob(job.id);
651
+ else {
652
+ unscheduleJob(job.id);
653
+ notifyJobDeath(updated, updated.lastError, false);
654
+ }
542
655
  return { ok: false, status: updated.status, job: updated, error: updated.lastError };
543
656
  }
544
657
 
@@ -722,32 +835,8 @@ function createSchedulerService(dependencies = {}) {
722
835
  try { return markFailure(job, { code: "SCHEDULED_ADAPTER_UNAVAILABLE", message: "Saved channel adapter is unavailable" }); }
723
836
  catch (error) { return keepRetrying(job, error); }
724
837
  }
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
- };
838
+ const chat = jobChat(job, adapter);
839
+ const { canonicalUserId } = chat;
751
840
 
752
841
  return runInChat(chat, async () => {
753
842
  noteLegacyCreatorOnce(job);
@@ -837,8 +926,23 @@ function createSchedulerService(dependencies = {}) {
837
926
  } catch (_) { /* advisory only */ }
838
927
  }
839
928
  let result;
840
- try { result = await runInProvider(prompt, runContext.cwd, null, { ...runOptions, runContext }); }
841
- catch (error) {
929
+ try {
930
+ result = await runInProvider(prompt, runContext.cwd, null, { ...runOptions, runContext });
931
+ // A scheduled turn whose whole reply is the resume placeholder did
932
+ // nothing (the runner never posts it). Nudge it once, in the same
933
+ // session; never more than once.
934
+ if (terminalRunSucceeded(result) && isPlaceholderResult(result)) {
935
+ const sessionId = result.sessionId || runContext.sessionId || null;
936
+ console.warn(`[scheduler] ${job.id}: the scheduled turn answered "No response requested." — re-running once with a nudge`);
937
+ const retryPrompt = sessionId ? PLACEHOLDER_NUDGE : `${prompt}\n\n${PLACEHOLDER_NUDGE}`;
938
+ const retryOptions = { ...runOptions, fresh: !sessionId, resumeSessionId: sessionId };
939
+ const retryContext = admit(retryPrompt, project.dir, null, retryOptions);
940
+ result = await runInProvider(retryPrompt, retryContext.cwd, null, { ...retryOptions, runContext: retryContext });
941
+ if (terminalRunSucceeded(result) && isPlaceholderResult(result)) {
942
+ console.warn(`[scheduler] ${job.id}: the nudged re-run answered "No response requested." too — not retrying again`);
943
+ }
944
+ }
945
+ } catch (error) {
842
946
  try { return markFailure(running, error, { attemptAlreadyCounted: true }); }
843
947
  catch (persistError) { return keepRetrying(running, persistError); }
844
948
  }
@@ -920,13 +1024,10 @@ function createSchedulerService(dependencies = {}) {
920
1024
  const fireAt = Number(job.fireAt || 0);
921
1025
  const overdue = now() - fireAt;
922
1026
  if (overdue > missedGraceMs) {
1027
+ const lastError = { code: "MISSED_WAKEUP_EXPIRED", message: "Wakeup exceeded the missed-fire grace window" };
923
1028
  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
- });
1029
+ jobsStore.update(job.id, { status: "dead-letter", disabled: true, nextAttemptAt: null, lastError });
1030
+ notifyJobDeath(job, lastError, false);
930
1031
  } catch (_) {}
931
1032
  return false;
932
1033
  }
@@ -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,