@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.
- package/.env.example +10 -0
- package/CHANGELOG.md +53 -0
- package/README.md +4 -0
- package/bin/cli.js +1 -1
- package/bin/task.js +51 -0
- package/bot.js +26 -0
- package/channels/kazee/adapter.js +130 -21
- package/channels/telegram/adapter.js +4 -2
- package/core/actions.js +8 -0
- package/core/config.js +22 -1
- package/core/cost-report.js +3 -0
- package/core/cron-schedule.js +59 -0
- package/core/dream.js +141 -34
- package/core/guardrail.js +5 -0
- package/core/handlers.js +78 -17
- package/core/lessons.js +11 -5
- package/core/limit-pickup.js +313 -0
- package/core/loopback.js +29 -0
- package/core/providers/claude-events.js +79 -0
- package/core/providers/codex.js +48 -0
- package/core/providers/events.js +4 -1
- package/core/relationship.js +1 -1
- package/core/router.js +16 -1
- package/core/run-context.js +3 -0
- package/core/runner.js +440 -14
- package/core/safe-filename.js +47 -0
- package/core/scheduler.js +170 -38
- package/core/state.js +91 -0
- package/core/system-prompt.js +89 -16
- package/core/tasks.js +208 -6
- package/core/turn-journal.js +508 -0
- package/core/usage-limits.js +321 -0
- package/core/utility-agent.js +17 -1
- package/package.json +4 -4
- package/test-fixtures/fake-agent-cli.js +26 -2
- package/test-guardrail.js +19 -0
- package/test-kazee-context-media.js +1 -1
- package/test-provider-compaction.js +66 -1
- package/test-provider-dream.js +3 -1
- package/test-provider-events.js +106 -0
- package/test-provider-language.js +16 -1
- package/test-provider-pack-review.js +3 -1
- package/test-provider-scheduler.js +72 -1
- package/test-thread-lanes.js +6 -1
- package/test-usage-accounting.js +4 -0
- 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
|
-
: (
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2479
|
-
|
|
2480
|
-
|
|
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,
|