@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
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,
@@ -87,12 +87,25 @@ function cancelledQueueResult(item, reason) {
87
87
  };
88
88
  }
89
89
 
90
+ // A pick-up waiting for a usage limit to reset (core/limit-pickup.js) in
91
+ // the current conversation, dropped by /stop, /new and /end.
92
+ function dropLimitPickup(reason, state = currentState(), laneKey = currentLaneKey(state)) {
93
+ try { return require("./limit-pickup").cancel(state.userId, laneKey, reason); } catch (_) { return null; }
94
+ }
95
+
96
+ function pickupDroppedNote(count) {
97
+ return count === 1
98
+ ? "I won't pick up your earlier message when the usage limit resets."
99
+ : `I won't pick up your ${count} earlier messages when the usage limits reset.`;
100
+ }
101
+
90
102
  // Drops every message queued in one lane: each one's caller gets a cancelled
91
103
  // result (no hanging promise), and an idle lane loses its typing heartbeat (the
92
104
  // chip would stay on, and the lane could never be pruned). Returns the items.
93
105
  function dropLaneQueue(lane, reason) {
94
106
  if (!lane) return [];
95
107
  const queued = lane.messageQueue.splice(0);
108
+ try { require("./turn-journal").dropQueued(lane.key, queued.map((item) => item?.runContext?.runId).filter(Boolean)); } catch (_) {}
96
109
  lane.waitingForSlot = false;
97
110
  for (const item of queued) {
98
111
  try { item?.disposeCallListener?.(); } catch (_) {}
@@ -592,7 +605,8 @@ register({
592
605
  if (!ownerEnv(env)) return;
593
606
  await send("Going offline for a quick restart — back in a moment.");
594
607
  try { require("./restart-reason").recordExitReason("User /restart command", { code: 0 }); } catch (e) {}
595
- setTimeout(() => process.exit(0), 1000);
608
+ // Running turns get DRAIN_TIMEOUT_S to finish; the rest resume after boot.
609
+ setTimeout(() => { require("./turn-journal").exitAfterDrain(0); }, 1000);
596
610
  },
597
611
  });
598
612
 
@@ -854,7 +868,7 @@ register({
854
868
  try { require("./boot-health").recordPendingDoctor({ reason: "source overlay upgrade", from: CURRENT_VERSION, to: newVersion }); } catch (e) {}
855
869
  try { require("./safe-mode").clearCrashLoop(); } catch (e) {}
856
870
  try { require("./restart-reason").recordExitReason(`User /upgrade → v${newVersion} (source refresh)`, { code: 0 }); } catch (e) {}
857
- setTimeout(() => process.exit(0), 2000);
871
+ setTimeout(() => { require("./turn-journal").exitAfterDrain(0); }, 2000);
858
872
  return;
859
873
  }
860
874
  try { process.chdir(process.env.HOME || require("os").homedir()); } catch (e) {}
@@ -918,7 +932,7 @@ register({
918
932
  await send(`Upgrade failed:\n${errOutput}`);
919
933
  return;
920
934
  }
921
- setTimeout(() => process.exit(0), 2000);
935
+ setTimeout(() => { require("./turn-journal").exitAfterDrain(0); }, 2000);
922
936
  },
923
937
  });
924
938
 
@@ -982,7 +996,7 @@ register({
982
996
  try { require("./boot-health").recordPendingDoctor({ reason: "downgrade overlay", from: CURRENT_VERSION, to: resolved }); } catch (e) {}
983
997
  try { safeModeLib.clearCrashLoop(); } catch (e) {}
984
998
  try { require("./restart-reason").recordExitReason(`User /downgrade → v${resolved} (source refresh)`, { code: 0 }); } catch (e) {}
985
- setTimeout(() => process.exit(0), 2000);
999
+ setTimeout(() => { require("./turn-journal").exitAfterDrain(0); }, 2000);
986
1000
  return;
987
1001
  }
988
1002
  try { process.chdir(process.env.HOME || require("os").homedir()); } catch (e) {}
@@ -1002,7 +1016,7 @@ register({
1002
1016
  await send(`Downgrade failed:\n${errOutput}`);
1003
1017
  return;
1004
1018
  }
1005
- setTimeout(() => process.exit(0), 2000);
1019
+ setTimeout(() => { require("./turn-journal").exitAfterDrain(0); }, 2000);
1006
1020
  },
1007
1021
  });
1008
1022
 
@@ -1020,7 +1034,7 @@ register({
1020
1034
  safeModeLib.requestContinue();
1021
1035
  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
1036
  try { require("./restart-reason").recordExitReason("User /safemode continue override", { code: 0 }); } catch (e) {}
1023
- setTimeout(() => process.exit(0), 1500);
1037
+ setTimeout(() => { require("./turn-journal").exitAfterDrain(0); }, 1500);
1024
1038
  return;
1025
1039
  }
1026
1040
  if (safeModeLib.isSafeMode()) {
@@ -1537,6 +1551,7 @@ register({
1537
1551
  if (!requireSession()) return;
1538
1552
  const result = startNewConversation();
1539
1553
  if (!result.ok) return send(`Could not start a new conversation: ${result.error.message}`);
1554
+ dropLimitPickup("/new");
1540
1555
  return send(`Session: ${result.project}\nNew ${result.provider} conversation\n\nSend text, voice, or images.`);
1541
1556
  },
1542
1557
  });
@@ -1575,7 +1590,7 @@ function formatCompactWindow(state) {
1575
1590
  const fmt = (n) => n >= 1000 ? (n / 1000).toFixed(0) + "k" : String(n);
1576
1591
  const cw = state.settings?.compactWindow;
1577
1592
  if (cw === 0) return "off";
1578
- if (cw == null) return `default (${fmt(AUTO_COMPACT_TOKENS)})`;
1593
+ if (cw == null) return `default (${fmt(defaultCompactTokens(getProjectKey(state)))})`;
1579
1594
  return fmt(cw);
1580
1595
  }
1581
1596
 
@@ -1726,6 +1741,16 @@ function stopLane(state, lane) {
1726
1741
 
1727
1742
  const STOP_DROP_REASON = "Dropped by /stop before it started";
1728
1743
 
1744
+ // /stop never silently loses a queued message: each dropped one comes back
1745
+ // with a "Run it" button (kept 24 h, core/turn-journal).
1746
+ function dropOffer(items) {
1747
+ if (!items || !items.length) return {};
1748
+ try {
1749
+ const keyboard = require("./turn-journal").droppedKeyboard(items);
1750
+ return keyboard ? { keyboard } : {};
1751
+ } catch (_) { return {}; }
1752
+ }
1753
+
1729
1754
  // "Dropped 2 queued: "reboot pe2", "and check the…"": what /stop dropped, a
1730
1755
  // few words of each message (the first few in full, then a count).
1731
1756
  function droppedNote(items, kind) {
@@ -1780,11 +1805,17 @@ register({
1780
1805
  const dropped = lanes.flatMap((lane) => dropLaneQueue(lane, STOP_DROP_REASON));
1781
1806
  const stopped = lanes.filter((lane) => stopLane(state, lane)).length;
1782
1807
  const sideStopped = await sideCancellation;
1783
- if (!stopped && !dropped.length) return send(sideStopped ? "Cancelled side response." : "Nothing running.");
1808
+ let pickups = [];
1809
+ try { pickups = require("./limit-pickup").cancelAllFor(state.userId, "/stop all"); } catch (_) {}
1810
+ if (!stopped && !dropped.length) {
1811
+ if (sideStopped) return send("Cancelled side response.");
1812
+ return send(pickups.length ? pickupDroppedNote(pickups.length) : "Nothing running.");
1813
+ }
1784
1814
  const parts = [];
1785
1815
  if (stopped) parts.push(`Cancelled ${stopped} running conversation${stopped === 1 ? "" : "s"}.`);
1786
1816
  if (dropped.length) parts.push(droppedNote(dropped, stopped ? "queued" : "waiting"));
1787
- return send(parts.join(" "));
1817
+ if (pickups.length) parts.push(pickupDroppedNote(pickups.length));
1818
+ return send(parts.join(" "), dropOffer(dropped));
1788
1819
  }
1789
1820
  // /stop cancels the CURRENT conversation's turn only (other threads keep
1790
1821
  // running) and drops what was queued in it before the /stop: the person
@@ -1793,16 +1824,19 @@ register({
1793
1824
  const lane = laneFor(state);
1794
1825
  const sideCancellation = sideChatCoordinator.cancel(state.userId, lane.key);
1795
1826
  const dropped = dropLaneQueue(lane, STOP_DROP_REASON);
1827
+ // A pick-up waiting for a usage limit to reset is dropped too.
1828
+ const pickup = dropLimitPickup("/stop", state, lane.key);
1829
+ const pickupNote = pickup ? ` ${pickupDroppedNote(1)}` : "";
1796
1830
  if (stopLane(state, lane)) {
1797
1831
  await sideCancellation;
1798
- await send(dropped.length ? `Cancelled. ${droppedNote(dropped, "queued")}` : "Cancelled.");
1832
+ await send(`${dropped.length ? `Cancelled. ${droppedNote(dropped, "queued")}` : "Cancelled."}${pickupNote}`, dropOffer(dropped));
1799
1833
  } else if (dropped.length) {
1800
1834
  // Nothing to cancel: the dropped messages were waiting for a slot (or
1801
1835
  // queued behind a turn that is only finishing its tail).
1802
1836
  await sideCancellation;
1803
- await send(droppedNote(dropped, laneBusy(lane) ? "queued" : "waiting"));
1804
- } else if (await sideCancellation) await send("Cancelled side response.");
1805
- else await send("Nothing running.");
1837
+ await send(`${droppedNote(dropped, laneBusy(lane) ? "queued" : "waiting")}${pickupNote}`, dropOffer(dropped));
1838
+ } else if (await sideCancellation) await send(`Cancelled side response.${pickupNote}`);
1839
+ else await send(pickup ? pickupDroppedNote(1) : "Nothing running.");
1806
1840
  },
1807
1841
  });
1808
1842
 
@@ -1855,16 +1889,42 @@ register({
1855
1889
  const state = currentState();
1856
1890
  const result = endCurrentSession({ state });
1857
1891
  if (!result.ok) return send(`Could not end conversation: ${result.error.message}`);
1892
+ dropLimitPickup("/end", state);
1858
1893
  send("Ended. Send anything to start a fresh conversation.");
1859
1894
  },
1860
1895
  });
1861
1896
 
1897
+ // Plan limits as last reported (core/usage-limits.js), and this person's
1898
+ // pick-up in this conversation if a usage limit stopped a message.
1899
+ function planLimitLines(state = currentState()) {
1900
+ const lines = [];
1901
+ try {
1902
+ const limits = require("./usage-limits");
1903
+ const byProvider = new Map();
1904
+ for (const w of limits.knownWindows()) {
1905
+ if (!byProvider.has(w.provider)) byProvider.set(w.provider, []);
1906
+ byProvider.get(w.provider).push(w);
1907
+ }
1908
+ const order = { five_hour: 0, seven_day: 1 };
1909
+ for (const [provider, windows] of byProvider) {
1910
+ const parts = windows
1911
+ .sort((a, b) => (order[a.id] ?? 9) - (order[b.id] ?? 9))
1912
+ .map((w) => `${limits.windowLabel(w.id).replace(/ limit$/, "")} ${Math.round(w.usedPercent)}%${w.resetsAt ? ` (resets ${limits.formatResetTime(w.resetsAt)})` : ""}`);
1913
+ lines.push(`${limits.providerLabel(provider)} plan limits: ${parts.join(" · ")}`);
1914
+ }
1915
+ const pickup = require("./limit-pickup").get(state.userId, currentLaneKey(state));
1916
+ if (pickup) lines.push(`Picking up your stopped message ${limits.whenPhrase(pickup.runAt)} (/stop drops it).`);
1917
+ } catch (_) {}
1918
+ return lines;
1919
+ }
1920
+
1862
1921
  register({
1863
1922
  name: "usage", description: "Show token usage & cost for this session",
1864
1923
  handler: async (env) => {
1865
1924
  if (!authorized(env)) return;
1866
1925
  const u = currentState().sessionUsage;
1867
- if (u.turns === 0) return send("No usage yet in this session.");
1926
+ const limitLines = planLimitLines();
1927
+ if (u.turns === 0) return send(["No usage yet in this session.", ...limitLines].join("\n"));
1868
1928
  const fmt = (n) => n >= 1000 ? (n / 1000).toFixed(1) + "k" : String(n);
1869
1929
  const lines = [
1870
1930
  `*Session usage* (${u.turns} turn${u.turns === 1 ? "" : "s"})`,
@@ -1880,7 +1940,8 @@ register({
1880
1940
  if (u.subagentThreads > 0) {
1881
1941
  lines.push(`Provider sub-agents: ${u.subagentThreads} thread${u.subagentThreads === 1 ? "" : "s"} · ${fmt(u.subagentInputTokens || 0)} in / ${fmt(u.subagentOutputTokens || 0)} out (spawned by the model; not in the totals above)`);
1882
1942
  }
1883
- if (Number.isFinite(u.quotaUsedPercent)) {
1943
+ if (limitLines.length) lines.push(...limitLines);
1944
+ else if (Number.isFinite(u.quotaUsedPercent)) {
1884
1945
  lines.push(`Plan quota used: ${u.quotaUsedPercent}%${u.quotaResetsAt ? ` (resets ${String(u.quotaResetsAt).slice(0, 16).replace("T", " ")}Z)` : ""}`);
1885
1946
  }
1886
1947
  if (u.lastInputTokens > 200000) {
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
  };