@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/dream.js CHANGED
@@ -12,6 +12,7 @@
12
12
  const fs = require("fs");
13
13
  const path = require("path");
14
14
  const cron = require("node-cron");
15
+ const { scheduleTolerant } = require("./cron-schedule");
15
16
  const { atomicWriteFileSync } = require("./fsutil");
16
17
  const { memoryMutationQueue } = require("./memory-mutation-queue");
17
18
 
@@ -937,20 +938,40 @@ function writeDreamReport(data) {
937
938
 
938
939
  // Task-hygiene pass: surface tasks gone cold for the owner to decide on.
939
940
  // Flag-and-ask only — tasks are user intent, so dream NEVER edits or deletes
940
- // them; it just lists the stalest few in the morning report. Reports against
941
- // the owner's Telegram channel (where the dream summary lands).
941
+ // them; it just lists the stalest few in the morning report. It covers every
942
+ // task list on this bot (tasks.taskLists): task kinds are owner-only, so each
943
+ // list is the owner's, whether it lives in Telegram, a Kazee chat or another
944
+ // channel. (It used to read only the Telegram CHAT_ID list, so an owner who
945
+ // works in Kazee never got a report.) At most STALE_REPORT_MAX task lines in
946
+ // all, shared out across the lists, each naming its list.
942
947
  const STALE_REPORT_MAX = Number(process.env.DREAM_STALE_TASK_MAX) || 8;
943
- function staleTaskReport() {
948
+ function staleTaskReport(opts = {}) {
944
949
  try {
945
950
  const tasks = require("./tasks");
946
- const { CHAT_ID } = require("./config");
947
- if (!CHAT_ID) return "";
948
- const stale = tasks.staleRoots("telegram", String(CHAT_ID));
949
- if (!stale.length) return "";
950
- const shown = stale.slice(0, STALE_REPORT_MAX);
951
- const lines = shown.map((s) => ` - ${s.content} (${s.id}) · ${s.idleDays}d idle`);
952
- const more = stale.length > shown.length ? `\n …and ${stale.length - shown.length} more` : "";
953
- return `🧹 ${stale.length} task${stale.length === 1 ? "" : "s"} have gone cold (untouched >${tasks.STALE_DAYS}d). Worth closing or reviving?\n${lines.join("\n")}${more}\nClose with \`open-claudia task done <id>\`, or tell me to.`;
951
+ const lists = tasks.taskLists(opts.dir)
952
+ .map((list) => ({ ...list, stale: tasks.staleRootsOf(tasks.loadFile(list.file), { now: opts.now }) }))
953
+ .filter((list) => list.stale.length > 0)
954
+ .sort((a, b) => b.stale.length - a.stale.length);
955
+ if (!lists.length) return "";
956
+ const total = lists.reduce((n, list) => n + list.stale.length, 0);
957
+ const max = Number.isFinite(opts.max) ? opts.max : STALE_REPORT_MAX;
958
+ // Round-robin, oldest first within each list, so every list gets a line.
959
+ const shown = [];
960
+ for (let i = 0; shown.length < max; i += 1) {
961
+ const before = shown.length;
962
+ for (const list of lists) {
963
+ if (shown.length >= max) break;
964
+ if (list.stale[i]) shown.push({ list, task: list.stale[i] });
965
+ }
966
+ if (shown.length === before) break;
967
+ }
968
+ const clip = (text) => (String(text).length > 80 ? `${String(text).slice(0, 79)}…` : String(text));
969
+ const lines = shown.map(({ list, task }) => ` - [${list.label}] ${clip(task.content)} (${task.id}) · ${task.idleDays}d idle`);
970
+ const where = lists.length === 1 ? `in ${lists[0].label}` : `across ${lists.length} task lists`;
971
+ const more = total > shown.length
972
+ ? `\n …and ${total - shown.length} more (${lists.map((list) => `${list.label}: ${list.stale.length}`).join(" · ")})`
973
+ : "";
974
+ return `🧹 ${total} task${total === 1 ? "" : "s"} have gone cold (untouched >${tasks.STALE_DAYS}d) ${where}. Worth closing or reviving?\n${lines.join("\n")}${more}\nClose with \`open-claudia task done <id>\` in that chat, or tell me to.`;
954
975
  } catch (e) { return ""; }
955
976
  }
956
977
 
@@ -1566,37 +1587,123 @@ const defaultDreamService = createDreamService();
1566
1587
  const runDream = defaultDreamService.runDream;
1567
1588
  const previewDream = defaultDreamService.previewDream;
1568
1589
 
1569
- // In-bot scheduler: one quiet-hours cron, reporting to the owner's
1570
- // Telegram chat. When the dream summary is on (default) it posts a short
1571
- // note after every run — including the charming "all tidy" line when
1572
- // nothing changed. /dreamsummary off mutes it to console only.
1573
- function initDream(adapters) {
1574
- if (!enabled()) return;
1575
- if (!cron.validate(DREAM_CRON)) {
1576
- console.error(`dream: invalid DREAM_CRON "${DREAM_CRON}" — dream disabled`);
1577
- return;
1590
+ // Where the dream summary goes: DREAM_SUMMARY_TO ("<adapter>:<channelId>") if
1591
+ // set; else the owner's primary handle from the people roster when its
1592
+ // adapter is loaded (a Kazee user-id handle resolves to the owner's DM); else
1593
+ // the Telegram owner chat (CHAT_ID). It used to go only to Telegram, so a
1594
+ // Kazee-only owner never saw it. null = nowhere to send.
1595
+ async function dreamSummaryTarget(adapters, deps = {}) {
1596
+ const list = adapters || [];
1597
+ const find = (name) => list.find((a) => a.id === name) || list.find((a) => a.type === name) || null;
1598
+ const to = String(deps.summaryTo ?? process.env.DREAM_SUMMARY_TO ?? config.DREAM_SUMMARY_TO ?? "").trim();
1599
+ if (to) {
1600
+ const cut = to.indexOf(":");
1601
+ const adapter = cut > 0 ? find(to.slice(0, cut)) : null;
1602
+ if (adapter && to.slice(cut + 1)) return { adapter, channelId: to.slice(cut + 1), via: "DREAM_SUMMARY_TO" };
1603
+ console.warn(`dream: DREAM_SUMMARY_TO "${to}" names no loaded adapter; using the owner's channel`);
1578
1604
  }
1579
- cron.schedule(DREAM_CRON, async () => {
1580
- try {
1581
- const res = await runDream({ trigger: "cron" });
1582
- if (res.skipped) { console.log(`dream: skipped — ${res.skipped}`); return; }
1583
- console.log(`dream: applied ${res.applied.length} change(s)`);
1584
- if (!summaryEnabled()) { console.log("dream: summary muted (DREAM_SUMMARY=off)"); return; }
1585
- if (!res.message) return;
1586
- const { CHAT_ID } = require("./config");
1587
- const tg = (adapters || []).find((a) => a.type === "telegram");
1588
- if (tg && CHAT_ID) await tg.send(CHAT_ID, res.message);
1589
- } catch (e) {
1590
- console.error("dream: run failed:", e.message);
1605
+ try {
1606
+ const peopleLib = deps.people || require("./people");
1607
+ const owner = (peopleLib.owners() || [])[0];
1608
+ const handles = owner?.handles || [];
1609
+ const handle = owner?.primaryChannel
1610
+ ? handles.find((h) => h.adapter === owner.primaryChannel.adapter && String(h.channelId) === String(owner.primaryChannel.channelId))
1611
+ : null;
1612
+ const adapter = handle ? find(handle.adapter) : null;
1613
+ if (adapter) {
1614
+ const resolve = deps.resolveHandle || require("./relationship").resolveHandleSendTarget;
1615
+ const target = await resolve(handle);
1616
+ if (target?.channelId) return { adapter, channelId: String(target.channelId), via: "owner primary handle" };
1591
1617
  }
1618
+ } catch (e) { /* fall back to Telegram */ }
1619
+ const chatId = deps.chatId !== undefined ? deps.chatId : require("./config").CHAT_ID;
1620
+ const tg = find("telegram");
1621
+ if (tg && chatId) return { adapter: tg, channelId: String(chatId), via: "TELEGRAM_CHAT_ID" };
1622
+ return null;
1623
+ }
1624
+
1625
+ // One scheduled dream (cron or catch-up) with its summary.
1626
+ async function runScheduledDream(trigger, adapters, deps = {}) {
1627
+ try {
1628
+ const res = await (deps.runDream || runDream)({ trigger });
1629
+ if (res.skipped) { console.log(`dream: skipped — ${res.skipped}`); return res; }
1630
+ console.log(`dream: applied ${(res.applied || []).length} change(s)`);
1631
+ if (!summaryEnabled()) { console.log("dream: summary muted (DREAM_SUMMARY=off)"); return res; }
1632
+ if (!res.message) return res;
1633
+ const target = await dreamSummaryTarget(adapters, deps);
1634
+ if (!target) { console.warn("dream: no channel to send the summary to"); return res; }
1635
+ await target.adapter.send(target.channelId, res.message);
1636
+ return res;
1637
+ } catch (e) {
1638
+ console.error("dream: run failed:", e.message);
1639
+ return { failed: e.message };
1640
+ }
1641
+ }
1642
+
1643
+ // Catch-up: a dream that has not run for more than DREAM_CATCHUP_HOURS (26 h)
1644
+ // runs once now, checked at boot and hourly — a night the bot was down or the
1645
+ // cron slot was missed is not lost. At most one catch-up attempt per window
1646
+ // (stamped in the dream state, so a failing dream is not retried every hour),
1647
+ // and never beside a running dream.
1648
+ const DREAM_CATCHUP_MS = (Number(process.env.DREAM_CATCHUP_HOURS) || 26) * 3600000;
1649
+ function dreamOverdue(state, nowMs, windowMs = DREAM_CATCHUP_MS) {
1650
+ const last = Date.parse(state?.lastRun || "");
1651
+ if (Number.isFinite(last) && nowMs - last <= windowMs) return false;
1652
+ const tried = Date.parse(state?.lastCatchUpAt || "");
1653
+ return !(Number.isFinite(tried) && nowMs - tried <= windowMs);
1654
+ }
1655
+
1656
+ function createDreamCatchUp(adapters, deps = {}) {
1657
+ const now = deps.now || Date.now;
1658
+ const readState = deps.readState || readDreamState;
1659
+ const writeState = deps.writeState || writeDreamState;
1660
+ const isDreaming = deps.isDreaming || defaultDreamService.isDreaming;
1661
+ let inFlight = null;
1662
+ return async function catchUp(reason = "check") {
1663
+ if (inFlight || isDreaming()) return { skipped: "a dream is already running" };
1664
+ const state = readState() || {};
1665
+ if (!dreamOverdue(state, now())) return { skipped: "not overdue" };
1666
+ writeState({ ...state, lastCatchUpAt: new Date(now()).toISOString() });
1667
+ console.log(`dream: catch-up (${reason}) — last run ${state.lastRun || "never"}`);
1668
+ inFlight = runScheduledDream("catch-up", adapters, deps);
1669
+ try { return await inFlight; } finally { inFlight = null; }
1670
+ };
1671
+ }
1672
+
1673
+ // In-bot scheduler: one quiet-hours cron (tolerating a late start up to
1674
+ // DREAM_LATE_TOLERANCE_MS, see core/cron-schedule), a boot + hourly catch-up
1675
+ // check, and the summary to the owner's channel (dreamSummaryTarget). When the
1676
+ // dream summary is on (default) it posts a short note after every run —
1677
+ // including the charming "all tidy" line when nothing changed.
1678
+ // /dreamsummary off mutes it to console only.
1679
+ function initDream(adapters, deps = {}) {
1680
+ if (!enabled()) return null;
1681
+ const cronLib = deps.cron || cron;
1682
+ if (!cronLib.validate(DREAM_CRON)) {
1683
+ console.error(`dream: invalid DREAM_CRON "${DREAM_CRON}" — dream disabled`);
1684
+ return null;
1685
+ }
1686
+ const { DREAM_LATE_TOLERANCE_MS } = require("./config");
1687
+ const toleranceMs = Number.isFinite(deps.toleranceMs) ? deps.toleranceMs : DREAM_LATE_TOLERANCE_MS;
1688
+ const task = scheduleTolerant(DREAM_CRON, () => runScheduledDream("cron", adapters, deps), {
1689
+ cron: cronLib, toleranceMs, noOverlap: true, label: "dream",
1592
1690
  });
1593
- console.log(`dream: scheduled (${DREAM_CRON}, provider policy tier ${dreamTier()}, effort ${DREAM_EFFORT}, introspect ${introspectEnabled() ? "on" : "off"})`);
1691
+ const catchUp = createDreamCatchUp(adapters, deps);
1692
+ const setTimer = deps.setTimer || setTimeout;
1693
+ const setRepeat = deps.setInterval || setInterval;
1694
+ const boot = setTimer(() => { catchUp("boot").catch(() => {}); }, Number.isFinite(deps.bootDelayMs) ? deps.bootDelayMs : 60000);
1695
+ const hourly = setRepeat(() => { catchUp("hourly").catch(() => {}); }, 3600000);
1696
+ boot?.unref?.();
1697
+ hourly?.unref?.();
1698
+ console.log(`dream: scheduled (${DREAM_CRON}, late tolerance ${Math.round(toleranceMs / 60000)} min, provider policy tier ${dreamTier()}, effort ${DREAM_EFFORT}, introspect ${introspectEnabled() ? "on" : "off"})`);
1699
+ return { task, catchUp };
1594
1700
  }
1595
1701
 
1596
1702
  module.exports = {
1597
1703
  createDreamService, previewDream, runDream, initDream, buildDreamPrompt, parseDream, applyDream, manageAbilityTiers, nestPrefixFamilies, dreamTimeoutMs,
1598
1704
  buildIntrospectionPrompt, parseIntrospection, applyIntrospection, writeDreamReport,
1599
- summaryDelta, healthOf, mergeClusters, selectDumpDirs, journalBackfillDedupe,
1705
+ summaryDelta, healthOf, mergeClusters, selectDumpDirs, journalBackfillDedupe, staleTaskReport,
1706
+ dreamSummaryTarget, runScheduledDream, dreamOverdue, createDreamCatchUp, DREAM_CATCHUP_MS,
1600
1707
  coRescueEdgesPhase, negativeHebbianPhase, readDreamState, writeDreamState,
1601
1708
  enabled, summaryEnabled, introspectEnabled, selfApplyEnabled, dreamTier,
1602
1709
  DREAM_CRON, DREAM_EFFORT, DREAM_OUTPUT_SCHEMA, INTROSPECTION_OUTPUT_SCHEMA,
package/core/guardrail.js CHANGED
@@ -59,6 +59,10 @@ const MED_OPENER = /^(?:always|don'?t|do not|make sure|be sure|remember to|prefe
59
59
 
60
60
  // Junk that pattern-matches a directive but is never a rule.
61
61
  const STOP_RULES = new Set(["never mind", "nevermind", "stop it", "never ever", "don't worry", "do not worry"]);
62
+ // Reassurances open like directives but never carry a rule, however they go
63
+ // on: "Don't worry so much about that.", "Never mind, I found it." (the
64
+ // latter would otherwise read as a high-tier never-directive).
65
+ const REASSURANCE = /^(?:don'?t worry|do not worry|never ?mind)\b/i;
62
66
 
63
67
  function splitSentences(text) {
64
68
  return String(text || "")
@@ -92,6 +96,7 @@ function ruleOk(rule) {
92
96
  if (!rule || rule.length < MIN_RULE_CHARS) return false;
93
97
  const low = rule.toLowerCase();
94
98
  if (STOP_RULES.has(low)) return false;
99
+ if (REASSURANCE.test(rule)) return false;
95
100
  if (low.split(/\s+/).length < MIN_RULE_WORDS) return false;
96
101
  return true;
97
102
  }
package/core/handlers.js CHANGED
@@ -7,7 +7,7 @@ const path = require("path");
7
7
  const cronLib = require("node-cron");
8
8
  const { execSync } = require("child_process");
9
9
  const {
10
- WORKSPACE, FULL_PATH, AUTO_COMPACT_TOKENS, CONFIG_DIR,
10
+ WORKSPACE, FULL_PATH, CONFIG_DIR,
11
11
  config, saveEnvKey,
12
12
  CHAT_ID, discoverProviderExecutable, SOUL_FILE,
13
13
  } = require("./config");
@@ -38,7 +38,7 @@ const scheduler = require("./scheduler");
38
38
  const skillsLib = require("./skills");
39
39
  const packsLib = require("./packs");
40
40
  const {
41
- admitRunContext, runAgent, compactActiveSession, getActiveSessionId, effectiveCompactThreshold,
41
+ admitRunContext, runAgent, compactActiveSession, getActiveSessionId, effectiveCompactThreshold, defaultCompactTokens,
42
42
  } = require("./runner");
43
43
  const {
44
44
  getClaudeOAuthToken, saveClaudeOAuthToken,
@@ -93,6 +93,7 @@ function cancelledQueueResult(item, reason) {
93
93
  function dropLaneQueue(lane, reason) {
94
94
  if (!lane) return [];
95
95
  const queued = lane.messageQueue.splice(0);
96
+ try { require("./turn-journal").dropQueued(lane.key, queued.map((item) => item?.runContext?.runId).filter(Boolean)); } catch (_) {}
96
97
  lane.waitingForSlot = false;
97
98
  for (const item of queued) {
98
99
  try { item?.disposeCallListener?.(); } catch (_) {}
@@ -592,7 +593,8 @@ register({
592
593
  if (!ownerEnv(env)) return;
593
594
  await send("Going offline for a quick restart — back in a moment.");
594
595
  try { require("./restart-reason").recordExitReason("User /restart command", { code: 0 }); } catch (e) {}
595
- setTimeout(() => process.exit(0), 1000);
596
+ // Running turns get DRAIN_TIMEOUT_S to finish; the rest resume after boot.
597
+ setTimeout(() => { require("./turn-journal").exitAfterDrain(0); }, 1000);
596
598
  },
597
599
  });
598
600
 
@@ -854,7 +856,7 @@ register({
854
856
  try { require("./boot-health").recordPendingDoctor({ reason: "source overlay upgrade", from: CURRENT_VERSION, to: newVersion }); } catch (e) {}
855
857
  try { require("./safe-mode").clearCrashLoop(); } catch (e) {}
856
858
  try { require("./restart-reason").recordExitReason(`User /upgrade → v${newVersion} (source refresh)`, { code: 0 }); } catch (e) {}
857
- setTimeout(() => process.exit(0), 2000);
859
+ setTimeout(() => { require("./turn-journal").exitAfterDrain(0); }, 2000);
858
860
  return;
859
861
  }
860
862
  try { process.chdir(process.env.HOME || require("os").homedir()); } catch (e) {}
@@ -918,7 +920,7 @@ register({
918
920
  await send(`Upgrade failed:\n${errOutput}`);
919
921
  return;
920
922
  }
921
- setTimeout(() => process.exit(0), 2000);
923
+ setTimeout(() => { require("./turn-journal").exitAfterDrain(0); }, 2000);
922
924
  },
923
925
  });
924
926
 
@@ -982,7 +984,7 @@ register({
982
984
  try { require("./boot-health").recordPendingDoctor({ reason: "downgrade overlay", from: CURRENT_VERSION, to: resolved }); } catch (e) {}
983
985
  try { safeModeLib.clearCrashLoop(); } catch (e) {}
984
986
  try { require("./restart-reason").recordExitReason(`User /downgrade → v${resolved} (source refresh)`, { code: 0 }); } catch (e) {}
985
- setTimeout(() => process.exit(0), 2000);
987
+ setTimeout(() => { require("./turn-journal").exitAfterDrain(0); }, 2000);
986
988
  return;
987
989
  }
988
990
  try { process.chdir(process.env.HOME || require("os").homedir()); } catch (e) {}
@@ -1002,7 +1004,7 @@ register({
1002
1004
  await send(`Downgrade failed:\n${errOutput}`);
1003
1005
  return;
1004
1006
  }
1005
- setTimeout(() => process.exit(0), 2000);
1007
+ setTimeout(() => { require("./turn-journal").exitAfterDrain(0); }, 2000);
1006
1008
  },
1007
1009
  });
1008
1010
 
@@ -1020,7 +1022,7 @@ register({
1020
1022
  safeModeLib.requestContinue();
1021
1023
  await send("Override armed: the next boot runs normally even if the substrate is missing (keyword-only memory) and the crash-loop counter resets. Restarting now...");
1022
1024
  try { require("./restart-reason").recordExitReason("User /safemode continue override", { code: 0 }); } catch (e) {}
1023
- setTimeout(() => process.exit(0), 1500);
1025
+ setTimeout(() => { require("./turn-journal").exitAfterDrain(0); }, 1500);
1024
1026
  return;
1025
1027
  }
1026
1028
  if (safeModeLib.isSafeMode()) {
@@ -1575,7 +1577,7 @@ function formatCompactWindow(state) {
1575
1577
  const fmt = (n) => n >= 1000 ? (n / 1000).toFixed(0) + "k" : String(n);
1576
1578
  const cw = state.settings?.compactWindow;
1577
1579
  if (cw === 0) return "off";
1578
- if (cw == null) return `default (${fmt(AUTO_COMPACT_TOKENS)})`;
1580
+ if (cw == null) return `default (${fmt(defaultCompactTokens(getProjectKey(state)))})`;
1579
1581
  return fmt(cw);
1580
1582
  }
1581
1583
 
@@ -1726,6 +1728,16 @@ function stopLane(state, lane) {
1726
1728
 
1727
1729
  const STOP_DROP_REASON = "Dropped by /stop before it started";
1728
1730
 
1731
+ // /stop never silently loses a queued message: each dropped one comes back
1732
+ // with a "Run it" button (kept 24 h, core/turn-journal).
1733
+ function dropOffer(items) {
1734
+ if (!items || !items.length) return {};
1735
+ try {
1736
+ const keyboard = require("./turn-journal").droppedKeyboard(items);
1737
+ return keyboard ? { keyboard } : {};
1738
+ } catch (_) { return {}; }
1739
+ }
1740
+
1729
1741
  // "Dropped 2 queued: "reboot pe2", "and check the…"": what /stop dropped, a
1730
1742
  // few words of each message (the first few in full, then a count).
1731
1743
  function droppedNote(items, kind) {
@@ -1784,7 +1796,7 @@ register({
1784
1796
  const parts = [];
1785
1797
  if (stopped) parts.push(`Cancelled ${stopped} running conversation${stopped === 1 ? "" : "s"}.`);
1786
1798
  if (dropped.length) parts.push(droppedNote(dropped, stopped ? "queued" : "waiting"));
1787
- return send(parts.join(" "));
1799
+ return send(parts.join(" "), dropOffer(dropped));
1788
1800
  }
1789
1801
  // /stop cancels the CURRENT conversation's turn only (other threads keep
1790
1802
  // running) and drops what was queued in it before the /stop: the person
@@ -1795,12 +1807,12 @@ register({
1795
1807
  const dropped = dropLaneQueue(lane, STOP_DROP_REASON);
1796
1808
  if (stopLane(state, lane)) {
1797
1809
  await sideCancellation;
1798
- await send(dropped.length ? `Cancelled. ${droppedNote(dropped, "queued")}` : "Cancelled.");
1810
+ await send(dropped.length ? `Cancelled. ${droppedNote(dropped, "queued")}` : "Cancelled.", dropOffer(dropped));
1799
1811
  } else if (dropped.length) {
1800
1812
  // Nothing to cancel: the dropped messages were waiting for a slot (or
1801
1813
  // queued behind a turn that is only finishing its tail).
1802
1814
  await sideCancellation;
1803
- await send(droppedNote(dropped, laneBusy(lane) ? "queued" : "waiting"));
1815
+ await send(droppedNote(dropped, laneBusy(lane) ? "queued" : "waiting"), dropOffer(dropped));
1804
1816
  } else if (await sideCancellation) await send("Cancelled side response.");
1805
1817
  else await send("Nothing running.");
1806
1818
  },
package/core/lessons.js CHANGED
@@ -148,8 +148,15 @@ function listLessons() {
148
148
  // then most recently reinforced) survive the char budget. Returns "" when
149
149
  // there are none, so the system prompt stays clean on a fresh install.
150
150
  function loadLessonsBlock() {
151
+ const out = injectedLessons().map(lessonLine);
152
+ if (out.length === 0) return "";
153
+ return out.join("\n");
154
+ }
155
+
156
+ // The lessons the always-injected block carries, in its order, with meta.
157
+ function injectedLessons() {
151
158
  const lessons = readLessons();
152
- if (lessons.length === 0) return "";
159
+ if (lessons.length === 0) return [];
153
160
  const meta = reconcileMeta(lessons);
154
161
  const ranked = lessons.slice().sort((a, b) => {
155
162
  const ma = meta[a.id] || {}, mb = meta[b.id] || {};
@@ -162,11 +169,10 @@ function loadLessonsBlock() {
162
169
  for (const l of ranked) {
163
170
  const line = lessonLine(l);
164
171
  if (used + line.length + 1 > MAX_LESSONS_CHARS) break;
165
- out.push(line);
172
+ out.push({ ...l, ...(meta[l.id] || {}) });
166
173
  used += line.length + 1;
167
174
  }
168
- if (out.length === 0) return "";
169
- return out.join("\n");
175
+ return out;
170
176
  }
171
177
 
172
178
  // Add or reinforce a lesson. Dedupe is by normalized text (id), so the same
@@ -271,7 +277,7 @@ module.exports = {
271
277
  LESSONS_FILE, LESSONS_META_FILE,
272
278
  MAX_LESSONS, MAX_LESSON_CHARS, MAX_LESSONS_CHARS,
273
279
  normalize, lessonId, parseLessons,
274
- readLessons, listLessons, loadLessonsBlock,
280
+ readLessons, listLessons, loadLessonsBlock, injectedLessons, lessonLine,
275
281
  readMeta, writeMeta, reconcileMeta,
276
282
  addLesson, editLesson, removeLesson, saveLessonsRaw, lessonsExist,
277
283
  };
package/core/loopback.js CHANGED
@@ -91,12 +91,14 @@ const OWNER_ONLY_KINDS = new Set([
91
91
  "people-list", "people-show", "people-note",
92
92
  "task-add", "task-plan", "task-update", "task-remove",
93
93
  "task-list", "task-get", "task-tree", "task-clear-completed",
94
+ "task-archive", "task-restore", "task-archived",
94
95
  ]);
95
96
  const JSON_KINDS = new Set([
96
97
  "whoami",
97
98
  "schedule-wakeup", "cron-add", "cron-remove", "job-list",
98
99
  "watch-add", "watch-list", "watch-remove", "watch-toggle",
99
100
  "task-add", "task-plan", "task-update", "task-remove", "task-list", "task-get", "task-tree", "task-clear-completed",
101
+ "task-archive", "task-restore", "task-archived",
100
102
  "people-list", "people-show", "people-add", "people-note", "people-link", "people-unlink",
101
103
  "people-remove", "people-set-primary",
102
104
  "intros-list", "intros-approve", "intros-reject",
@@ -668,9 +670,12 @@ async function handleJson(req, res, url, kind) {
668
670
  if (kind === "task-add") {
669
671
  if (!payload.content) return reply(res, 400, { error: "missing content" });
670
672
  try {
673
+ // A task belongs to the conversation it was made in (the run's thread,
674
+ // null on the main timeline); a subtask follows its plan.
671
675
  const t = tasksStore.add(adapterId, channelId, payload.content, {
672
676
  parentId: payload.parentId || null,
673
677
  description: payload.description || null,
678
+ threadId: threadForCaller(callerOf(payload), channelId, payload.threadId),
674
679
  });
675
680
  return reply(res, 200, { ok: true, task: t });
676
681
  } catch (e) { return reply(res, 400, { error: e.message }); }
@@ -682,6 +687,7 @@ async function handleJson(req, res, url, kind) {
682
687
  if (subs.length === 0) return reply(res, 400, { error: "subtasks must be a non-empty array" });
683
688
  const result = tasksStore.plan(adapterId, channelId, payload.title, subs, {
684
689
  description: payload.description || null,
690
+ threadId: threadForCaller(callerOf(payload), channelId, payload.threadId),
685
691
  });
686
692
  return reply(res, 200, { ok: true, plan: result });
687
693
  }
@@ -723,6 +729,29 @@ async function handleJson(req, res, url, kind) {
723
729
  return reply(res, got ? 200 : 404, { ok: !!got, ...(got || {}) });
724
730
  }
725
731
 
732
+ if (kind === "task-archive") {
733
+ if (!payload.id) return reply(res, 400, { error: "missing id" });
734
+ try {
735
+ const result = tasksStore.archive(adapterId, channelId, payload.id, payload.reason);
736
+ return reply(res, result ? 200 : 404, { ok: !!result, ...(result || {}) });
737
+ } catch (e) { return reply(res, 400, { error: e.message }); }
738
+ }
739
+
740
+ if (kind === "task-restore") {
741
+ if (!payload.id) return reply(res, 400, { error: "missing id" });
742
+ try {
743
+ const result = tasksStore.restore(adapterId, channelId, payload.id);
744
+ return reply(res, result ? 200 : 404, { ok: !!result, ...(result || {}) });
745
+ } catch (e) { return reply(res, 400, { error: e.message }); }
746
+ }
747
+
748
+ if (kind === "task-archived") {
749
+ const limit = Number(payload.limit);
750
+ const opts = { search: payload.search || "" };
751
+ if (Number.isFinite(limit) && limit > 0) opts.limit = limit;
752
+ return reply(res, 200, { ok: true, tasks: tasksStore.archived(adapterId, channelId, opts) });
753
+ }
754
+
726
755
  if (kind === "task-clear-completed") {
727
756
  const remaining = tasksStore.clearCompleted(adapterId, channelId);
728
757
  return reply(res, 200, { ok: true, tasks: remaining });
@@ -58,6 +58,15 @@ function normalizeClaudeEvent(rawEvent, state = createClaudeParserState()) {
58
58
  const decoderFailure = normalizeDecoderError(rawEvent);
59
59
  if (decoderFailure) return [decoderFailure];
60
60
 
61
+ // A helper agent's (Agent/Task tool) own messages carry parent_tool_use_id:
62
+ // its working notes, tool calls and refusals are not this turn's reply.
63
+ // Only its spend still counts.
64
+ if (rawEvent.parent_tool_use_id && ["assistant", "stream_event", "user"].includes(rawEvent.type)) {
65
+ if (rawEvent.type !== "assistant") return [];
66
+ const usage = normalizedUsage(rawEvent.message?.usage, "provider_call", state.seenUsage);
67
+ return usage ? [usage] : [];
68
+ }
69
+
61
70
  if (rawEvent.type === "system" && rawEvent.subtype === "init") {
62
71
  const event = sessionEvent(rawEvent.session_id, state);
63
72
  return event ? [event] : [];
@@ -75,6 +75,52 @@ function imagePathsOf(context) {
75
75
  return paths;
76
76
  }
77
77
 
78
+ // Codex hands --output-schema to OpenAI in strict mode, which refuses the call
79
+ // (400 invalid_json_schema) when the root is not an object, or an object has no
80
+ // properties, allows extra keys or leaves a property out of `required`
81
+ // (6 Oct 2026: every passive-reply check, pack review, dedup and dream on Codex
82
+ // bots failed that way). An object whose properties are all required is closed
83
+ // with additionalProperties: false, which costs a reply nothing. A schema that
84
+ // needs optional or free-form fields gives null: no schema is sent, and the
85
+ // utility agent still checks the reply against the caller's full schema.
86
+ function strictOutputSchema(schema) {
87
+ function close(node, depth) {
88
+ if (!node || typeof node !== "object" || Array.isArray(node)) return depth > 0 ? node : null;
89
+ if (depth > 20 || node.$ref !== undefined) return null;
90
+ const copy = { ...node };
91
+ if ([].concat(node.type || []).includes("object")) {
92
+ const properties = node.properties && typeof node.properties === "object" ? node.properties : {};
93
+ const keys = Object.keys(properties);
94
+ if (!keys.length) return null;
95
+ if (node.additionalProperties && typeof node.additionalProperties === "object") return null;
96
+ const required = new Set(Array.isArray(node.required) ? node.required : []);
97
+ if (keys.some((key) => !required.has(key))) return null;
98
+ copy.properties = {};
99
+ for (const key of keys) {
100
+ const child = close(properties[key], depth + 1);
101
+ if (child === null) return null;
102
+ copy.properties[key] = child;
103
+ }
104
+ copy.additionalProperties = false;
105
+ }
106
+ if (node.items !== undefined) {
107
+ const items = Array.isArray(node.items) ? null : close(node.items, depth + 1);
108
+ if (items === null) return null;
109
+ copy.items = items;
110
+ }
111
+ for (const key of ["anyOf", "oneOf", "allOf"]) {
112
+ if (node[key] === undefined) continue;
113
+ if (!Array.isArray(node[key])) return null;
114
+ const variants = node[key].map((variant) => close(variant, depth + 1));
115
+ if (variants.includes(null)) return null;
116
+ copy[key] = variants;
117
+ }
118
+ return copy;
119
+ }
120
+ if (!schema || typeof schema !== "object" || schema.type !== "object") return null;
121
+ return close(schema, 0);
122
+ }
123
+
78
124
  function createCodexProvider(options = {}) {
79
125
  const resolveExecutable = options.resolveExecutable || (() => config.discoverProviderExecutable("codex"));
80
126
  const defaultModel = options.defaultModel ?? config.config.CODEX_MODEL ?? null;
@@ -266,6 +312,7 @@ function createCodexProvider(options = {}) {
266
312
  return buildInvocation({ ...context, permissionMode: "plan" }, false);
267
313
  },
268
314
  buildUtilityInvocation: (context) => buildInvocation({ ...context, fresh: true }, true),
315
+ outputSchemaFor: (schema) => strictOutputSchema(schema),
269
316
  createEventDecoder: (decoderOptions) => createJsonlDecoder(decoderOptions),
270
317
  normalizeEvent: normalizeCodexEvent,
271
318
  authStatus: () => getAuthStatus(executable()),
@@ -300,4 +347,5 @@ module.exports = {
300
347
  capabilities,
301
348
  codexProvider,
302
349
  createCodexProvider,
350
+ strictOutputSchema,
303
351
  };
@@ -323,4 +323,4 @@ function ensureContact(envelope) {
323
323
  }
324
324
  }
325
325
 
326
- module.exports = { speakerFor, currentSpeaker, guardActive, isCurrentSpeakerGuarded, ownerTarget, ownerTargets, configuredOwnerTarget, ensureContact };
326
+ module.exports = { speakerFor, currentSpeaker, guardActive, isCurrentSpeakerGuarded, ownerTarget, ownerTargets, configuredOwnerTarget, ensureContact, resolveHandleSendTarget };
package/core/router.js CHANGED
@@ -431,8 +431,13 @@ async function handleDocument(envelope) {
431
431
  attachments: [{ type: "document", mimeType: media.mimeType || null, fileName: media.fileName || null }],
432
432
  });
433
433
  stopPreTurnTyping = startPreTurnTyping(state);
434
+ // Saved before the guard runs: a held request resumes after the owner's
435
+ // approval from the guard's text alone (core/actions.js), so that text
436
+ // must say where the file is. The adapters keep the file inside FILES_DIR
437
+ // under a cleaned name (core/safe-filename.js), and that name is the one
438
+ // shown, never the sender's raw path.
434
439
  const savePath = await envelope.adapter.downloadMedia(media);
435
- const fileName = media.fileName || path.basename(savePath);
440
+ const fileName = path.basename(savePath);
436
441
  const mime = media.mimeType || "";
437
442
  const caption = envelope.caption || "";
438
443
  const isImage = mime.startsWith("image/");