@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/runner.js CHANGED
@@ -9,17 +9,21 @@ const crypto = require("crypto");
9
9
  const { spawn, execFileSync } = require("child_process");
10
10
  const {
11
11
  AUTO_COMPACT_TOKENS, MIN_COMPACT_INTERVAL_MS, MAX_PROCESS_TIMEOUT, COMPACT_SUMMARY_TIMEOUT,
12
+ TOKEN_DIET_CONVERSATIONS, TOKEN_DIET_COMPACT_TOKENS, TOKEN_DIET_COLD_MINUTES, TOKEN_DIET_COLD_MIN_TOKENS,
12
13
  CONFIG_DIR, WORKSPACE, config,
13
14
  } = require("./config");
14
15
  const {
15
16
  currentState, saveState, recordSession,
16
17
  resetSessionUsage, admitLane, cancelTargets, getActiveProvider, getProjectKey,
17
18
  laneFor, laneForRun, peekLane, laneBusy, runLaneKey, waitingLanes, slotBlocker, pruneIdleLanes,
18
- getProviderSession, setProviderSession, clearProviderSession, quarantineProviderSession, getUsageForSession, commitProviderCompaction,
19
- resolveProviderSettings,
19
+ getProviderSession, setProviderSession, clearProviderSession, quarantineProviderSession, getUsageForSession, commitProviderCompaction, commitBranchCompaction,
20
+ resolveProviderSettings, getProjectSessions,
20
21
  } = require("./state");
21
22
  const { chatContext, currentChannelId, currentAdapter, currentThreadId } = require("./context");
22
23
  const { buildTurnPrompt } = require("./system-prompt");
24
+ const turnJournal = require("./turn-journal");
25
+ const usageLimits = require("./usage-limits");
26
+ const limitPickup = require("./limit-pickup");
23
27
  const { getProvider, resolveProviderForRun } = require("./providers");
24
28
  const { validateInvocation } = require("./providers/contract");
25
29
  const { selectTerminalText } = require("./providers/events");
@@ -143,6 +147,9 @@ function applyUsageToState(state, usage, costUsd, opts = {}) {
143
147
  ? getUsageForSession(state, project, backend, sessionId)
144
148
  : state.sessionUsage;
145
149
  u.turns += 1;
150
+ // When the conversation's last turn ended: past the prompt cache's lifetime
151
+ // a resume re-writes the whole context (token diet cold-resume check).
152
+ u.lastTurnAt = Date.now();
146
153
  u.inputTokens += parts.input;
147
154
  u.outputTokens += parts.output;
148
155
  u.cacheReadTokens += parts.cacheRead;
@@ -313,6 +320,8 @@ async function executeProviderInvocation(options = {}) {
313
320
  const cleanupOutcome = { ok: true, skipped: false };
314
321
  const rawErrors = [];
315
322
  const usageEvents = [];
323
+ // The provider's last plan-limit reading this run (Claude's rate_limit_event).
324
+ let rateLimit = null;
316
325
  const tools = [];
317
326
  const textDeltas = [];
318
327
  const textFinals = [];
@@ -398,6 +407,7 @@ async function executeProviderInvocation(options = {}) {
398
407
  tools.push(event);
399
408
  }
400
409
  else if (event.type === "usage") usageEvents.push(event);
410
+ else if (event.type === "rate_limit") rateLimit = event;
401
411
  else if (event.type === "result") {
402
412
  terminalSeen = true;
403
413
  if (event.sessionId) sessionId = event.sessionId;
@@ -593,6 +603,7 @@ async function executeProviderInvocation(options = {}) {
593
603
  usage: finalUsage?.usage || null,
594
604
  usageScope: finalUsage?.scope || null,
595
605
  usageEvents,
606
+ rateLimit,
596
607
  costUsd: Number.isFinite(invocation.parserState.totalCostUsd) ? invocation.parserState.totalCostUsd : 0,
597
608
  tools,
598
609
  exitCode: closeInfo.code,
@@ -992,12 +1003,38 @@ function resolveRunCwd(cwd) {
992
1003
 
993
1004
  const CODEX_WINDOW_COMPACT_FRACTION = 0.8;
994
1005
 
1006
+ // Token diet canary (core/config.js). `opts.tokenDiet` overrides the
1007
+ // configured values (tests).
1008
+ function tokenDietSettings(override = null) {
1009
+ const num = (value, fallback) => (Number.isFinite(value) && value > 0 ? value : fallback);
1010
+ const o = override || {};
1011
+ return {
1012
+ conversations: Array.isArray(o.conversations) ? o.conversations : TOKEN_DIET_CONVERSATIONS,
1013
+ compactTokens: num(o.compactTokens ?? TOKEN_DIET_COMPACT_TOKENS, 200000),
1014
+ coldMinutes: num(o.coldMinutes ?? TOKEN_DIET_COLD_MINUTES, 60),
1015
+ coldMinTokens: num(o.coldMinTokens ?? TOKEN_DIET_COLD_MIN_TOKENS, 120000),
1016
+ };
1017
+ }
1018
+
1019
+ function tokenDietListed(conversationKey, override = null) {
1020
+ const key = String(conversationKey || "");
1021
+ if (!key) return false;
1022
+ const list = tokenDietSettings(override).conversations;
1023
+ return list.includes("*") || list.includes(key);
1024
+ }
1025
+
1026
+ // The threshold a conversation compacts at when /compactwindow sets none.
1027
+ function defaultCompactTokens(conversationKey, override = null) {
1028
+ if (tokenDietListed(conversationKey, override)) return tokenDietSettings(override).compactTokens;
1029
+ return Number.isFinite(AUTO_COMPACT_TOKENS) ? AUTO_COMPACT_TOKENS : 380000;
1030
+ }
1031
+
995
1032
  function effectiveCompactThreshold(state = currentState(), opts = {}) {
996
1033
  const override = state.settings?.compactWindow;
997
1034
  if (override === 0) return 0;
998
1035
  const base = Number.isFinite(override) && override > 0
999
1036
  ? override
1000
- : (Number.isFinite(AUTO_COMPACT_TOKENS) ? AUTO_COMPACT_TOKENS : 380000);
1037
+ : defaultCompactTokens(opts.conversationKey || getProjectKey(state), opts.tokenDiet);
1001
1038
  // Codex's window (258k for gpt-6-astra) sits below the default threshold, so
1002
1039
  // measured honestly the bot would never compact a Codex session — and would
1003
1040
  // lose the brief that feeds memory. Compact at 80% of the reported window.
@@ -1011,7 +1048,7 @@ function effectiveCompactThreshold(state = currentState(), opts = {}) {
1011
1048
  function shouldAutoCompact(state = currentState(), opts = {}) {
1012
1049
  if (opts.skipAutoCompact || opts.fresh || opts.continueSession || peekLane(state, opts.laneKey)?.isCompacting) return false;
1013
1050
  if (!getProviderSession(state, getActiveProvider(state), getProjectKey(state))) return false;
1014
- const threshold = effectiveCompactThreshold(state);
1051
+ const threshold = effectiveCompactThreshold(state, { tokenDiet: opts.tokenDiet });
1015
1052
  if (threshold === 0) return false;
1016
1053
  const activeUsage = state.sessionUsage;
1017
1054
  const contextTokens = activeUsage?.liveContextTokens || activeUsage?.lastInputTokens || 0;
@@ -1025,10 +1062,199 @@ function shouldAutoCompact(state = currentState(), opts = {}) {
1025
1062
  return true;
1026
1063
  }
1027
1064
 
1065
+ // Token diet: compact BEFORE a turn that resumes a listed conversation whose
1066
+ // prompt cache has expired (last turn ended more than coldMinutes ago) while
1067
+ // its context is large: resuming would re-write the whole context at the cache
1068
+ // write price, then keep re-reading it every step. Compacting first costs one
1069
+ // summary read and the turn runs on a small fresh session. Never for
1070
+ // compaction stages, fresh or continue runs, a live call, a lane already
1071
+ // compacting, or within MIN_COMPACT_INTERVAL of the last compaction.
1072
+ function lastTurnEndedAt(state, usage, provider, project, sessionId) {
1073
+ const at = Number(usage?.lastTurnAt);
1074
+ if (Number.isFinite(at) && at > 0) return at;
1075
+ // Usage recorded before lastTurnAt existed: the session history's lastUsed.
1076
+ try {
1077
+ const record = getProjectSessions(state.userId, project, provider).find((entry) => entry.id === sessionId);
1078
+ const used = Date.parse(record?.lastUsed || "");
1079
+ return Number.isFinite(used) ? used : 0;
1080
+ } catch (_) { return 0; }
1081
+ }
1082
+
1083
+ function coldResumeDecision(state, runContext, opts = {}) {
1084
+ const now = Number.isFinite(opts.now) ? opts.now : Date.now();
1085
+ const key = runContext?.project?.name || null;
1086
+ const no = (reason, extra = {}) => ({ compact: false, reason, key, ...extra });
1087
+ if (!tokenDietListed(key, opts.tokenDiet)) return no("not-listed");
1088
+ const purpose = runContext.purpose;
1089
+ if (purpose === "compact-summary" || purpose === "compact-seed") return no("compaction-run");
1090
+ if (runContext.fresh || runContext.continueSession || runContext.liveCall) return no("fresh-or-continue");
1091
+ const provider = runContext.provider;
1092
+ const sessionId = runContext.sessionId || null;
1093
+ if (!sessionId) return no("no-session");
1094
+ // A scheduled job on its own conversation (a branch: its thread has moved
1095
+ // on) gets the same cap, on that branch. Any other run only on the session
1096
+ // its conversation points at.
1097
+ const branch = getProviderSession(state, provider, key) !== sessionId;
1098
+ if (branch && !isJobBranchRun(runContext)) return no("no-session");
1099
+ if (peekLane(state, opts.laneKey || runLaneKey(runContext))?.isCompacting) return no("compacting");
1100
+ if (effectiveCompactThreshold(state, { conversationKey: key, tokenDiet: opts.tokenDiet }) === 0) return no("compaction-off");
1101
+ const settings = tokenDietSettings(opts.tokenDiet);
1102
+ const usage = getUsageForSession(state, key, provider, sessionId);
1103
+ const endedAt = lastTurnEndedAt(state, usage, provider, key, sessionId);
1104
+ if (!endedAt) return no("never-ran");
1105
+ const idleMinutes = Math.floor((now - endedAt) / 60000);
1106
+ const contextTokens = usage?.liveContextTokens || usage?.lastInputTokens || 0;
1107
+ if (now - endedAt <= settings.coldMinutes * 60000) return no("warm", { idleMinutes, contextTokens });
1108
+ if (contextTokens <= settings.coldMinTokens) return no("small", { idleMinutes, contextTokens });
1109
+ const minInterval = Number.isFinite(MIN_COMPACT_INTERVAL_MS) ? MIN_COMPACT_INTERVAL_MS : 1800000;
1110
+ const checkpointTime = Date.parse(usage?.compactionCheckpoint?.at || "");
1111
+ const lastCompactedAt = Number(usage?.lastCompactedAt) || (Number.isFinite(checkpointTime) ? checkpointTime : 0);
1112
+ if (lastCompactedAt && now - lastCompactedAt < minInterval) return no("recently-compacted", { idleMinutes, contextTokens });
1113
+ return { compact: true, reason: "cold-resume", key, idleMinutes, contextTokens, ...(branch ? { sessionId } : {}) };
1114
+ }
1115
+
1116
+ // A scheduled run that resumes the job's own conversation without becoming
1117
+ // its thread's live session (scheduler: attachSession false).
1118
+ function isJobBranchRun(runContext) {
1119
+ return !!runContext?.scheduledJobId && runContext.attachSession === false;
1120
+ }
1121
+
1122
+ // The threshold rule for a scheduled job's own conversation, checked after
1123
+ // its run: the thread's rule (shouldAutoCompact) reads only the thread's live
1124
+ // session, so a job's branch grew without limit (Kazee Bugs monitor, 5-7 Oct:
1125
+ // 201k to 894k, $54.90 a wake). Same threshold and the same spacing.
1126
+ function jobBranchThresholdDecision(state, runContext, result, opts = {}) {
1127
+ const key = runContext?.project?.name || null;
1128
+ const no = (reason, extra = {}) => ({ compact: false, reason, key, ...extra });
1129
+ if (!isJobBranchRun(runContext) || !runContext.sessionId) return no("not-a-job-branch");
1130
+ const provider = runContext.provider;
1131
+ const sessionId = result?.sessionId || runContext.sessionId;
1132
+ if (!key || getProviderSession(state, provider, key) === sessionId) return no("live");
1133
+ if (peekLane(state, opts.laneKey || runLaneKey(runContext))?.isCompacting) return no("compacting");
1134
+ const usage = getUsageForSession(state, key, provider, sessionId);
1135
+ const threshold = effectiveCompactThreshold(state, {
1136
+ conversationKey: key,
1137
+ tokenDiet: opts.tokenDiet,
1138
+ contextWindowTokens: usage?.contextWindowTokens,
1139
+ });
1140
+ if (threshold === 0) return no("compaction-off");
1141
+ const contextTokens = usage?.liveContextTokens || usage?.lastInputTokens || 0;
1142
+ if (contextTokens < threshold) return no("under", { contextTokens });
1143
+ const now = Number.isFinite(opts.now) ? opts.now : Date.now();
1144
+ const minInterval = Number.isFinite(MIN_COMPACT_INTERVAL_MS) ? MIN_COMPACT_INTERVAL_MS : 1800000;
1145
+ const checkpointTime = Date.parse(usage?.compactionCheckpoint?.at || "");
1146
+ const lastCompactedAt = Number(usage?.lastCompactedAt) || (Number.isFinite(checkpointTime) ? checkpointTime : 0);
1147
+ if (contextTokens < threshold * 2 && lastCompactedAt && now - lastCompactedAt < minInterval) {
1148
+ return no("recently-compacted", { contextTokens });
1149
+ }
1150
+ return { compact: true, reason: "threshold", key, sessionId, contextTokens };
1151
+ }
1152
+
1153
+ // Runs the cold-resume compaction for a turn that holds its lane, and returns
1154
+ // the turn's run context on the fresh session (or the unchanged one).
1155
+ async function compactColdResume(runContext, state, lane, deps = {}) {
1156
+ const decide = deps.decide || coldResumeDecision;
1157
+ const compact = deps.compact || compactActiveSession;
1158
+ let decision;
1159
+ try { decision = decide(state, runContext, { laneKey: lane.key, now: deps.now, tokenDiet: deps.tokenDiet }); }
1160
+ catch (error) {
1161
+ console.warn(`[token-diet] cold-resume check failed: ${redactSensitive(error.message)}`);
1162
+ return runContext;
1163
+ }
1164
+ if (!decision.compact) {
1165
+ // Only a cold resume that was held back is worth a line; warm turns and
1166
+ // unlisted conversations are the normal case.
1167
+ if (decision.idleMinutes !== undefined && decision.reason !== "warm") {
1168
+ console.log(`[token-diet] cold-resume skip ${decision.key} reason=${decision.reason} ctx=${decision.contextTokens} idle=${decision.idleMinutes}m`);
1169
+ }
1170
+ return runContext;
1171
+ }
1172
+ const branch = decision.sessionId || null;
1173
+ console.log(`[token-diet] cold-resume compact ${decision.key} ctx=${decision.contextTokens} idle=${decision.idleMinutes}m${branch ? ` job-session=${String(branch).slice(0, 8)}` : ""}`);
1174
+ let result;
1175
+ try {
1176
+ result = await compact(runContext.project.dir, {
1177
+ notify: false, laneKey: lane.key, admissionHeld: true,
1178
+ compactReason: "cold-resume", contextTokensBefore: decision.contextTokens,
1179
+ ...(branch ? { sessionId: branch, provider: runContext.provider } : {}),
1180
+ });
1181
+ } catch (error) {
1182
+ console.warn(`[token-diet] cold-resume compaction failed: ${redactSensitive(error.message)}`);
1183
+ return runContext;
1184
+ }
1185
+ if (!result?.compacted || !result.newSessionId) {
1186
+ console.warn(`[token-diet] cold-resume compaction did not complete: ${result?.reason || "unknown"}`);
1187
+ return runContext;
1188
+ }
1189
+ if (branch) {
1190
+ // The job runs on its compacted conversation; the thread's live pointer
1191
+ // (previousSessionId) and the run's detachment from it stay as they were.
1192
+ return deepFreeze({
1193
+ ...runContext,
1194
+ sessionId: result.newSessionId,
1195
+ admittedSessionId: result.newSessionId,
1196
+ resumeSessionId: result.newSessionId,
1197
+ continueSession: false,
1198
+ fresh: false,
1199
+ });
1200
+ }
1201
+ return deepFreeze({
1202
+ ...runContext,
1203
+ sessionId: result.newSessionId,
1204
+ admittedSessionId: result.newSessionId,
1205
+ previousSessionId: result.newSessionId,
1206
+ resumeSessionId: result.newSessionId,
1207
+ continueSession: false,
1208
+ fresh: false,
1209
+ });
1210
+ }
1211
+
1212
+ // One usage-ledger record per compaction (its provider stages run with
1213
+ // persistence off, so they never got a per-turn record): why it ran, the
1214
+ // context it replaced, and what the summary + seed runs cost. Kept apart from
1215
+ // turn records (kind "compaction", tokens nested) so turn counts and averages
1216
+ // stay comparable with the history before it.
1217
+ function recordCompactionUsage(entry, deps = {}) {
1218
+ const append = deps.appendUsageRecord || appendUsageRecord;
1219
+ const totals = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreationTokens: 0, costUsd: 0 };
1220
+ for (const run of [entry.summaryRun, entry.seedRun]) {
1221
+ if (!run) continue;
1222
+ if (run.usage) {
1223
+ try {
1224
+ const parts = usageParts(run.usage, entry.provider);
1225
+ totals.inputTokens += parts.input;
1226
+ totals.outputTokens += parts.output;
1227
+ totals.cacheReadTokens += parts.cacheRead;
1228
+ totals.cacheCreationTokens += parts.cacheCreation;
1229
+ } catch (_) { /* unknown provider: tokens stay 0 */ }
1230
+ }
1231
+ if (typeof run.costUsd === "number") totals.costUsd += run.costUsd;
1232
+ }
1233
+ return append({
1234
+ ts: new Date().toISOString(),
1235
+ version: PKG_VERSION,
1236
+ kind: "compaction",
1237
+ compactReason: entry.compactReason || "manual",
1238
+ // A scheduled job's own conversation, not its thread's live session.
1239
+ ...(entry.branch ? { scope: "job" } : {}),
1240
+ backend: entry.provider,
1241
+ provider: entry.provider,
1242
+ model: entry.model || null,
1243
+ userId: entry.userId || null,
1244
+ project: entry.project || null,
1245
+ previousSessionId: entry.previousSessionId || null,
1246
+ newSessionId: entry.newSessionId || null,
1247
+ ok: !!entry.ok,
1248
+ contextTokensBefore: Number.isFinite(entry.contextTokensBefore) ? entry.contextTokensBefore : null,
1249
+ compactionUsage: totals,
1250
+ });
1251
+ }
1252
+
1028
1253
  function compactSummaryPrompt() {
1029
1254
  return [
1030
1255
  "Summarize this conversation for a fresh compacted continuation.",
1031
1256
  "Write a brief that lets your future self resume work without re-asking the user.",
1257
+ "Write it now, in this one reply, from what is already in this conversation. Do NOT use any tools or run any commands (no searches, file reads or status checks): this step allows one reply only, so a tool call ends it with no brief and the compaction fails. The live repo state is attached to the seed for you; if you are unsure of a fact, say so in the brief.",
1032
1258
  "",
1033
1259
  "Include:",
1034
1260
  "- Current user goal and any active side threads or sub-goals.",
@@ -1305,12 +1531,14 @@ function createCompactionService(dependencies = {}) {
1305
1531
  const captureContext = dependencies.captureRunContext || admitRunContext;
1306
1532
  const runCapture = dependencies.runCapture || runAgentCapture;
1307
1533
  const commitCompaction = dependencies.commitCompaction || commitProviderCompaction;
1534
+ const commitBranch = dependencies.commitBranchCompaction || commitBranchCompaction;
1308
1535
  const archiveBrief = dependencies.archiveBrief || archiveCompactionBrief;
1309
1536
  const appendDaySeed = dependencies.appendDaySeed || ((entry) => require("./day-seeds").appendDaySeed(entry));
1310
1537
  const collectRepoFacts = dependencies.collectRepoFacts || collectRepoStateFacts;
1311
1538
  const collectRecent = dependencies.collectRecentTurns || collectRecentTurns;
1312
1539
  const ackSchemaPath = dependencies.ackSchemaPath || ensureCompactionAckSchemaPath;
1313
1540
  const drainQueue = dependencies.drainQueue || drainQueuedMessages;
1541
+ const recordCompaction = dependencies.recordCompaction || recordCompactionUsage;
1314
1542
 
1315
1543
  // Compaction acts on ONE conversation: the lane it is asked from (/compact)
1316
1544
  // or the lane whose turn just finished (auto-compaction, admissionHeld).
@@ -1322,9 +1550,14 @@ function createCompactionService(dependencies = {}) {
1322
1550
  if (lane.runningProcess || ((lane.preparingRun || lane.settling) && !opts.admissionHeld)) {
1323
1551
  return { compacted: false, reason: "Finish or stop the active run before compacting." };
1324
1552
  }
1325
- const provider = getActiveProvider(state);
1553
+ const provider = opts.provider || getActiveProvider(state);
1326
1554
  const project = getProjectKey(state);
1327
- const oldSessionId = getProviderSession(state, provider, project);
1555
+ const liveSessionId = getProviderSession(state, provider, project);
1556
+ // opts.sessionId names a scheduled job's own conversation (a branch beside
1557
+ // the thread's live session): that is what gets compacted, and only the
1558
+ // job follows it; the thread's pointer stays where it is.
1559
+ const branch = !!opts.sessionId && opts.sessionId !== liveSessionId;
1560
+ const oldSessionId = branch ? opts.sessionId : liveSessionId;
1328
1561
  if (!project || !oldSessionId) return { compacted: false, reason: "No conversation." };
1329
1562
  const summaryPrompt = compactSummaryPrompt();
1330
1563
  const admitted = captureContext(summaryPrompt, cwd, null, {
@@ -1349,10 +1582,17 @@ function createCompactionService(dependencies = {}) {
1349
1582
  // The recent turns come from THIS conversation's transcript: the captured
1350
1583
  // project, not the state's legacy "Workspace" session (which read another file).
1351
1584
  const recentTurns = collectRecent(capturedStateView(state, summaryContext));
1585
+ const usageBefore = branch ? getUsageForSession(state, project, provider, oldSessionId) : state.sessionUsage;
1586
+ const contextTokensBefore = Number.isFinite(opts.contextTokensBefore)
1587
+ ? opts.contextTokensBefore
1588
+ : (usageBefore?.liveContextTokens || usageBefore?.lastInputTokens || 0);
1589
+ let summaryRun = null;
1590
+ let seedRun = null;
1591
+ let committedSessionId = null;
1352
1592
  lane.isCompacting = true;
1353
1593
  try {
1354
1594
  if (opts.notify) await send(opts.message || "Context is getting large, compacting first so this stays fast…");
1355
- const summaryRun = await runCapture(summaryPrompt, summaryContext.cwd || cwd, {
1595
+ summaryRun = await runCapture(summaryPrompt, summaryContext.cwd || cwd, {
1356
1596
  runContext: summaryContext,
1357
1597
  persist: false,
1358
1598
  });
@@ -1385,7 +1625,7 @@ function createCompactionService(dependencies = {}) {
1385
1625
  outputSchemaPath,
1386
1626
  recallQuery: seedRecallQuery(condensed || fullBrief),
1387
1627
  });
1388
- const seedRun = await runCapture(seedPrompt, seedContext.cwd || cwd, {
1628
+ seedRun = await runCapture(seedPrompt, seedContext.cwd || cwd, {
1389
1629
  runContext: seedContext,
1390
1630
  persist: false,
1391
1631
  });
@@ -1397,19 +1637,20 @@ function createCompactionService(dependencies = {}) {
1397
1637
  }
1398
1638
  let commit;
1399
1639
  try {
1400
- commit = await commitCompaction(state, {
1640
+ commit = await (branch ? commitBranch : commitCompaction)(state, {
1401
1641
  operationId,
1402
1642
  canonicalUserId: summaryContext.canonicalUserId,
1403
1643
  project: summaryContext.project.name,
1404
1644
  provider: summaryContext.provider,
1405
1645
  previousSessionId: oldSessionId,
1406
1646
  newSessionId: seedRun.sessionId,
1407
- title: `Compacted ${new Date().toLocaleDateString()}`,
1647
+ title: branch ? null : `Compacted ${new Date().toLocaleDateString()}`,
1408
1648
  seedUsage: seedRun.usage || null,
1409
1649
  });
1410
1650
  } catch (error) {
1411
1651
  return { compacted: false, reason: `Compaction commit failed: ${redactSensitive(error.message)}` };
1412
1652
  }
1653
+ committedSessionId = seedRun.sessionId;
1413
1654
  let briefPath = null;
1414
1655
  try { briefPath = archiveBrief(fullBrief, capturedStateView(state, summaryContext)); } catch (_) {}
1415
1656
  try {
@@ -1429,9 +1670,29 @@ function createCompactionService(dependencies = {}) {
1429
1670
  project: summaryContext.project.name,
1430
1671
  summary,
1431
1672
  commit,
1673
+ compactReason: opts.compactReason || "manual",
1674
+ ...(branch ? { branch: true } : {}),
1432
1675
  };
1433
1676
  } finally {
1434
1677
  lane.isCompacting = false;
1678
+ if (summaryRun) {
1679
+ try {
1680
+ recordCompaction({
1681
+ compactReason: opts.compactReason || "manual",
1682
+ branch,
1683
+ provider: summaryContext.provider,
1684
+ model: summaryContext.providerSettings?.model || null,
1685
+ userId: summaryContext.canonicalUserId,
1686
+ project: summaryContext.project.name,
1687
+ previousSessionId: oldSessionId,
1688
+ newSessionId: committedSessionId,
1689
+ ok: !!committedSessionId,
1690
+ contextTokensBefore,
1691
+ summaryRun,
1692
+ seedRun,
1693
+ });
1694
+ } catch (error) { console.warn(`[compact] usage record failed: ${redactSensitive(error.message)}`); }
1695
+ }
1435
1696
  // A bare cancel flag names no run: whatever it was for is over now.
1436
1697
  if (lane.cancelRequested === true) lane.cancelRequested = false;
1437
1698
  try { await drainQueue(state); }
@@ -1474,6 +1735,58 @@ function capturedTranscriptMetadata(runContext, extra = {}) {
1474
1735
  };
1475
1736
  }
1476
1737
 
1738
+ // Plan limits (core/usage-limits.js, core/limit-pickup.js). Worked out once
1739
+ // per run and kept on the result (not enumerable: never part of its shape).
1740
+ function keepOnResult(result, key, value) {
1741
+ try { Object.defineProperty(result, key, { value, enumerable: false, configurable: true, writable: true }); } catch (_) {}
1742
+ return value;
1743
+ }
1744
+
1745
+ // Each plan window's use after this run: Claude reports it in the stream;
1746
+ // Codex in its rollout (read for the ledger on success, here on a failure).
1747
+ function planLimitWindows(result, runContext) {
1748
+ if (result.planWindows !== undefined) return result.planWindows;
1749
+ let windows = [];
1750
+ try {
1751
+ if (runContext.provider === "claude") windows = result.rateLimit?.windows || [];
1752
+ else if (runContext.provider === "codex") {
1753
+ const rateLimits = result.codexRateLimits !== undefined
1754
+ ? result.codexRateLimits
1755
+ : (result.sessionId ? readCodexRollout({ threadId: result.sessionId, includeSubagents: false })?.rateLimits || null : null);
1756
+ windows = usageLimits.codexWindows(rateLimits);
1757
+ }
1758
+ } catch (_) { windows = []; }
1759
+ return keepOnResult(result, "planWindows", windows);
1760
+ }
1761
+
1762
+ // What a turn a usage limit stopped does: the notice it posts and, when the
1763
+ // reset time is known, the pick-up deliverForegroundResult arms.
1764
+ function limitPlanFor(result, runContext) {
1765
+ if (result.limitPlan !== undefined) return result.limitPlan;
1766
+ let plan = null;
1767
+ if (!result.ok && result.error?.code === "PROVIDER_USAGE_LIMIT") {
1768
+ let guarded = false;
1769
+ try { guarded = require("./relationship").isCurrentSpeakerGuarded(); } catch (_) {}
1770
+ try { plan = limitPickup.planFor(result, runContext, { windows: planLimitWindows(result, runContext), guarded }); }
1771
+ catch (error) { console.warn(`[limit-pickup] plan failed: ${redactSensitive(String(error.message || error))}`); }
1772
+ }
1773
+ return keepOnResult(result, "limitPlan", plan);
1774
+ }
1775
+
1776
+ // Remember each window's use; the warnings now due (80% / 95%, once per
1777
+ // window). A run that cannot show them only updates the use, so the warning
1778
+ // is still due on the next turn that can.
1779
+ function recordPlanLimits(result, runContext, { warn = true } = {}) {
1780
+ try {
1781
+ const windows = planLimitWindows(result, runContext);
1782
+ if (!windows.length) return [];
1783
+ return usageLimits.recordWindows({ provider: runContext.provider, windows, ...(warn ? {} : { percents: [] }) });
1784
+ } catch (error) {
1785
+ console.warn(`[usage-limits] ${redactSensitive(String(error.message || error))}`);
1786
+ return [];
1787
+ }
1788
+ }
1789
+
1477
1790
  function providerRunDiagnostic(result, runContext) {
1478
1791
  if (result.ok) return null;
1479
1792
  let provider;
@@ -1486,6 +1799,8 @@ function providerRunDiagnostic(result, runContext) {
1486
1799
  return `${label} authentication failed.${help ? `\n\n${help}` : ""}`;
1487
1800
  }
1488
1801
  if (code === "PROVIDER_USAGE_LIMIT") {
1802
+ const plan = limitPlanFor(result, runContext);
1803
+ if (plan?.notice) return plan.notice;
1489
1804
  return `${label} reported a usage or rate limit. Check that provider's account status or select another configured provider.`;
1490
1805
  }
1491
1806
  if (code === "PROVIDER_REFUSAL") {
@@ -1589,6 +1904,7 @@ async function persistForegroundUsage(result, runContext, state = currentState()
1589
1904
  liveContextTokens = rollout && rollout.lastContextTokens > 0
1590
1905
  ? rollout.lastContextTokens
1591
1906
  : usageParts(rawUsage, "codex").context;
1907
+ keepOnResult(result, "codexRateLimits", rollout ? rollout.rateLimits || null : null);
1592
1908
  }
1593
1909
  const rolloutOpts = rollout ? {
1594
1910
  contextSource: "rollout_last_request",
@@ -1714,6 +2030,20 @@ async function finalizeStreamPreview(lane, result, runContext = null) {
1714
2030
  // words. That is never an answer, so it is never posted.
1715
2031
  const PLACEHOLDER_REPLY = /^no response requested\.?$/i;
1716
2032
 
2033
+ // "⚠️ Claude: 82% of the 5-hour limit is used. It resets at 17:20." goes out
2034
+ // after the answer that crossed it. Never to an outside contact (plan
2035
+ // internals), never on a live call, never after an unasked group reply.
2036
+ function limitWarningsVisible(result, runContext) {
2037
+ if (!result.ok || runContext.passive === true || runContext.liveCall || statusChatterMuted()) return false;
2038
+ try { return !require("./relationship").isCurrentSpeakerGuarded(); } catch (_) { return false; }
2039
+ }
2040
+
2041
+ async function sendLimitWarnings(due) {
2042
+ for (const item of due) {
2043
+ await send(usageLimits.formatWarning(item)).catch(() => {});
2044
+ }
2045
+ }
2046
+
1717
2047
  async function deliverForegroundResult(result, runContext, state = currentState()) {
1718
2048
  const lane = laneForRun(state, runContext);
1719
2049
  if (runContext.lastInputWasVoice) lane.lastInputWasVoice = false;
@@ -1727,6 +2057,17 @@ async function deliverForegroundResult(result, runContext, state = currentState(
1727
2057
  if (stoppedHere) lane.cancelRequested = false;
1728
2058
  return { ok: true, suppressed: "cancelled" };
1729
2059
  }
2060
+ // Plan limits: each window's use is remembered (a warning is due once per
2061
+ // window at 80% and 95%), and a turn a usage limit stopped is picked up
2062
+ // when the limit resets, unless the person writes again first.
2063
+ const limitWarnings = recordPlanLimits(result, runContext, { warn: limitWarningsVisible(result, runContext) });
2064
+ const limitPlan = limitPlanFor(result, runContext);
2065
+ if (limitPlan?.armable) {
2066
+ try { limitPickup.arm(runContext, limitPlan); }
2067
+ catch (error) { console.warn(`[limit-pickup] could not arm: ${redactSensitive(String(error.message || error))}`); }
2068
+ }
2069
+ // Nobody asked for a passive reply: a limit that stopped one is not news.
2070
+ if (limitPlan && runContext.passive === true) return { ok: true, held: true, suppressed: "passive" };
1730
2071
  const diagnostic = providerRunDiagnostic(result, runContext);
1731
2072
  if (diagnostic) result.diagnostic = diagnostic;
1732
2073
  const cleanAnswer = String(result.finalAnswerText || "").trim();
@@ -1778,6 +2119,7 @@ async function deliverForegroundResult(result, runContext, state = currentState(
1778
2119
  if (!sent.ok) sent = await send(chunks[index], telegramHtmlOpts({ botTags: true }));
1779
2120
  if (!sent.ok) throw new Error(`failed to deliver response chunk ${index + 1}`);
1780
2121
  }
2122
+ if (limitWarnings.length) await sendLimitWarnings(limitWarnings);
1781
2123
 
1782
2124
  const shouldSpeak = shouldReplyWithVoice(VOICE_REPLY_MODE, runContext.lastInputWasVoice);
1783
2125
  if (result.ok && shouldSpeak) {
@@ -2358,6 +2700,33 @@ function quarantineRefusedRun(result, runContext, state) {
2358
2700
  }
2359
2701
  }
2360
2702
 
2703
+ // A resume whose saved conversation no longer exists on disk (its transcript file is gone, or
2704
+ // was never written) fails EVERY turn of that conversation with "No conversation found with
2705
+ // session ID" until someone sends /new (chat-spaces-bot's Agent Space chat, 6 Oct 2026).
2706
+ // Recovery: retire the stale id like a refused session, then replay the message once in a
2707
+ // fresh session. Never for captures, background work, or a run that is itself a retry.
2708
+ const MISSING_CONVERSATION = /No conversation found with session ID/i;
2709
+ function shouldRetryMissingConversation(result, runContext, { capture = false, refusalRetried = false } = {}) {
2710
+ return !capture
2711
+ && !refusalRetried
2712
+ && runContext?.purpose === "foreground"
2713
+ && !!runContext?.sessionId
2714
+ && !!result
2715
+ && !result.ok
2716
+ && MISSING_CONVERSATION.test(`${result.error?.message || ""}\n${result.stderr || ""}`);
2717
+ }
2718
+
2719
+ function retireMissingConversation(runContext, state) {
2720
+ try {
2721
+ quarantineProviderSession(state, runContext.provider, runContext.project?.name, runContext.sessionId, { reason: "missing" });
2722
+ saveState({ strict: true });
2723
+ return true;
2724
+ } catch (error) {
2725
+ console.error("missing-conversation reset failed:", redactSensitive(String(error.message || error)));
2726
+ return false;
2727
+ }
2728
+ }
2729
+
2361
2730
  function refusalRetryContext(runContext, state) {
2362
2731
  return deepFreeze({
2363
2732
  ...runContext,
@@ -2412,6 +2781,9 @@ async function runAdmitted(runContext, state, store, capture, ownsAdmissionLock,
2412
2781
  const persistCapture = executionOptions.persistCapture !== false;
2413
2782
  const internalCapture = capture && !persistCapture;
2414
2783
  if (tracksMainRun) lane.activeRunContext = runContext;
2784
+ // Turn journal "lock": a foreground chat turn that a restart cuts short is
2785
+ // resumed after boot (core/turn-journal).
2786
+ if (tracksMainRun) turnJournal.begin(runContext);
2415
2787
  if (!internalCapture) runContext = consumeOneShotBudget(runContext, state, executionOptions);
2416
2788
  const stopTyping = startTypingHeartbeat(state, store, runLaneKey(runContext));
2417
2789
  const holdsLane = () => ownsAdmissionLock
@@ -2423,6 +2795,15 @@ async function runAdmitted(runContext, state, store, capture, ownsAdmissionLock,
2423
2795
  });
2424
2796
  let result;
2425
2797
  try {
2798
+ // Token diet: a listed conversation resumed after its prompt cache expired
2799
+ // is compacted first and the turn runs on the fresh session.
2800
+ if (!capture && ownsAdmissionLock && !executionOptions.refusalRetried && sameCapturedProject(state, runContext)) {
2801
+ const rebased = await compactColdResume(runContext, state, lane);
2802
+ if (rebased !== runContext) {
2803
+ if (tracksMainRun && lane.activeRunContext === runContext) lane.activeRunContext = rebased;
2804
+ runContext = rebased;
2805
+ }
2806
+ }
2426
2807
  result = await service.executeCaptured(runContext.userPrompt, runContext, { capture });
2427
2808
  if ((result.persistence.transcript.skipped || (!capture && result.delivery.skipped)) && !internalCapture) {
2428
2809
  result = await settleImmediateEffects(result, runContext, { state, store, capture });
@@ -2457,6 +2838,14 @@ async function runAdmitted(runContext, state, store, capture, ownsAdmissionLock,
2457
2838
  return runAdmitted(refusalRetryContext(runContext, state), state, store, capture, ownsAdmissionLock, { ...executionOptions, refusalRetried: true });
2458
2839
  }
2459
2840
 
2841
+ if (shouldRetryMissingConversation(result, runContext, { capture, refusalRetried: executionOptions.refusalRetried })
2842
+ && retireMissingConversation(runContext, state)) {
2843
+ try { service.turnObserver?.onTurnSettled(false); } catch (e) { /* best-effort */ }
2844
+ if (holdsLane()) lane.preparingRun = true;
2845
+ await send("♻️ This conversation's saved history is missing, so I started a fresh one and am answering your message there.").catch(() => {});
2846
+ return runAdmitted(refusalRetryContext(runContext, state), state, store, capture, ownsAdmissionLock, { ...executionOptions, refusalRetried: true });
2847
+ }
2848
+
2460
2849
  if (tracksMainRun) {
2461
2850
  sideChatCoordinator.noteMainResult(runContext, result);
2462
2851
  for (const item of lane.messageQueue) {
@@ -2473,11 +2862,27 @@ async function runAdmitted(runContext, state, store, capture, ownsAdmissionLock,
2473
2862
  // A thread's first delivered reply may name the thread. Detached: it never
2474
2863
  // delays this turn, the lane or the queue behind it.
2475
2864
  if (!capture) nameThreadAfterTurn(result, runContext, store);
2865
+ // "Unlock": the turn ended — answered, cancelled or failed. (A shutdown's
2866
+ // kill keeps it journaled; see turn-journal.end.)
2867
+ if (tracksMainRun) turnJournal.end(runContext);
2476
2868
  // Auto-compaction is part of the lane's turn: only when nothing waits in
2477
- // this conversation, and it compacts this conversation only.
2478
- if (!capture && !runContext.skipAutoCompact && result.ok && lane.messageQueue.length === 0 && sameCapturedProject(state, runContext) && shouldAutoCompact(state, { laneKey: lane.key })) {
2479
- try { await compactActiveSession(runContext.project.dir, { notify: false, laneKey: lane.key, admissionHeld: ownsAdmissionLock }); }
2480
- catch (error) { console.warn(`[proactive-compact] failed: ${redactSensitive(error.message)}`); }
2869
+ // this conversation, and it compacts this conversation only. A scheduled
2870
+ // job on its own conversation (a branch) is held to the same threshold.
2871
+ if (!capture && !runContext.skipAutoCompact && result.ok && lane.messageQueue.length === 0 && sameCapturedProject(state, runContext)) {
2872
+ let jobBranch = { compact: false };
2873
+ try { jobBranch = jobBranchThresholdDecision(state, runContext, result, { laneKey: lane.key }); }
2874
+ catch (error) { console.warn(`[proactive-compact] job conversation check failed: ${redactSensitive(error.message)}`); }
2875
+ if (jobBranch.compact) console.log(`[proactive-compact] job conversation ${jobBranch.key} job-session=${String(jobBranch.sessionId).slice(0, 8)} ctx=${jobBranch.contextTokens}`);
2876
+ if (jobBranch.compact || shouldAutoCompact(state, { laneKey: lane.key })) {
2877
+ try {
2878
+ const outcome = await compactActiveSession(runContext.project.dir, {
2879
+ notify: false, laneKey: lane.key, admissionHeld: ownsAdmissionLock, compactReason: "threshold",
2880
+ ...(jobBranch.compact ? { sessionId: jobBranch.sessionId, provider: runContext.provider, contextTokensBefore: jobBranch.contextTokens } : {}),
2881
+ });
2882
+ // A compaction that runs but does not commit returns its reason instead of throwing.
2883
+ if (outcome && outcome.compacted === false) console.warn(`[proactive-compact] did not complete: ${redactSensitive(String(outcome.reason || "unknown"))}`);
2884
+ } catch (error) { console.warn(`[proactive-compact] failed: ${redactSensitive(error.message)}`); }
2885
+ }
2481
2886
  }
2482
2887
  return result;
2483
2888
  }
@@ -2560,6 +2965,18 @@ function runAgent(prompt, cwd, replyToMsgId, opts = {}) {
2560
2965
  });
2561
2966
 
2562
2967
  if (obsoleteCallTurn(store)) return Promise.resolve(cancelledCallRunResult(runContext));
2968
+ // A turn the person starts here (a button, a restored message) drops their
2969
+ // pick-up waiting for a usage limit to reset; the pick-up's own run does not.
2970
+ if (runContext.purpose === "foreground" && !runContext.limitPickup && !runContext.liveCall) {
2971
+ try { limitPickup.cancel(runContext.canonicalUserId, runLaneKey(runContext), "a new turn"); } catch (_) {}
2972
+ }
2973
+ // A planned restart is draining: no new turn starts now. The message is
2974
+ // journaled as queued and runs right after the restart.
2975
+ if (turnJournal.isDraining() && turnJournal.journalable(runContext)) {
2976
+ turnJournal.enqueue(runContext);
2977
+ send("Restarting in a moment — I'll pick this up as soon as I'm back.", { replyTo: replyToMsgId }).catch(() => {});
2978
+ return Promise.resolve({ ok: false, status: "deferred", runId: runContext.runId, error: { code: "RESTARTING", message: "Queued until after the restart" } });
2979
+ }
2563
2980
  const admission = admitLane(state, runLaneKey(runContext));
2564
2981
  if (!admission.ok) {
2565
2982
  const lane = admission.lane;
@@ -2574,6 +2991,7 @@ function runAgent(prompt, cwd, replyToMsgId, opts = {}) {
2574
2991
  // `text` is what the person typed (before reply/context blocks were added),
2575
2992
  // so /stop can say which queued messages it dropped.
2576
2993
  const item = queueInLane(lane, { runContext, store, deferred, queuedAt: Date.now(), text: admitted.userPrompt || prompt });
2994
+ turnJournal.enqueue(runContext);
2577
2995
  cancelQueuedCallOnAbort(lane, item);
2578
2996
  // Waiting for a slot: admit whatever the free slots allow, oldest first.
2579
2997
  // This message may be among them, and then it needs no notice.
@@ -2715,6 +3133,8 @@ module.exports = {
2715
3133
  shouldRetryRefusedRun,
2716
3134
  quarantineRefusedRun,
2717
3135
  refusalRetryContext,
3136
+ shouldRetryMissingConversation,
3137
+ retireMissingConversation,
2718
3138
  shouldStartSideChat,
2719
3139
  runSideChatRequest,
2720
3140
  enqueueSideChatRequest,
@@ -2723,6 +3143,12 @@ module.exports = {
2723
3143
  deriveCompactionRunContext,
2724
3144
  getActiveSessionId,
2725
3145
  shouldAutoCompact,
3146
+ tokenDietListed,
3147
+ defaultCompactTokens,
3148
+ coldResumeDecision,
3149
+ compactColdResume,
3150
+ jobBranchThresholdDecision,
3151
+ recordCompactionUsage,
2726
3152
  effectiveCompactThreshold,
2727
3153
  usageParts,
2728
3154
  peakContextTokens,