@inetafrica/open-claudia 3.5.14 → 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/CHANGELOG.md +58 -0
- package/Dockerfile +3 -1
- package/README.md +1 -0
- package/bin/cli.js +1 -1
- package/bin/task.js +51 -0
- package/bot.js +19 -0
- package/channels/kazee/adapter.js +304 -53
- package/channels/kazee/bot-handoffs.js +230 -0
- package/channels/telegram/adapter.js +4 -2
- package/core/actions.js +8 -0
- package/core/browser-view.js +200 -0
- package/core/config.js +19 -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 +24 -12
- package/core/lessons.js +11 -5
- package/core/loopback.js +29 -0
- package/core/passive-replies.js +4 -2
- package/core/providers/claude-events.js +9 -0
- package/core/providers/codex-hook.js +5 -1
- package/core/providers/codex.js +48 -0
- package/core/relationship.js +1 -1
- package/core/router.js +6 -1
- package/core/runner.js +264 -8
- package/core/safe-filename.js +47 -0
- package/core/scheduler.js +137 -36
- package/core/system-prompt.js +89 -16
- package/core/tasks.js +208 -6
- package/core/turn-journal.js +506 -0
- package/core/utility-agent.js +17 -1
- package/package.json +4 -4
- package/test-fixtures/fake-agent-cli.js +17 -1
- package/test-guardrail.js +19 -0
- package/test-kazee-context-media.js +1 -1
- package/test-provider-dream.js +3 -1
- package/test-provider-events.js +21 -0
- package/test-provider-language.js +20 -1
- package/test-provider-pack-review.js +3 -1
- package/test-provider-scheduler.js +3 -1
- package/test-provider-tool-hooks.js +17 -0
- package/test-thread-lanes.js +18 -4
- package/web.js +148 -50
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
|
-
|
|
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
|
|
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
|
|
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
|
-
};
|
|
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 {
|
|
841
|
-
|
|
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
|
-
|
|
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
|
}
|
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,
|