@inetafrica/open-claudia 3.5.17 → 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 +18 -0
- package/README.md +3 -0
- package/bot.js +7 -0
- package/core/config.js +9 -6
- package/core/handlers.js +56 -7
- package/core/limit-pickup.js +313 -0
- package/core/providers/claude-events.js +70 -0
- package/core/providers/events.js +4 -1
- package/core/router.js +10 -0
- package/core/run-context.js +3 -0
- package/core/runner.js +186 -13
- package/core/scheduler.js +33 -2
- package/core/state.js +91 -0
- package/core/turn-journal.js +2 -0
- package/core/usage-limits.js +321 -0
- package/package.json +2 -2
- package/test-fixtures/fake-agent-cli.js +22 -1
- package/test-provider-compaction.js +66 -1
- package/test-provider-events.js +85 -0
- package/test-provider-language.js +9 -1
- package/test-provider-scheduler.js +69 -0
- package/test-usage-accounting.js +4 -0
package/core/providers/events.js
CHANGED
|
@@ -11,6 +11,7 @@ const NORMALIZED_EVENT_TYPES = Object.freeze([
|
|
|
11
11
|
"result",
|
|
12
12
|
"error",
|
|
13
13
|
"warning",
|
|
14
|
+
"rate_limit",
|
|
14
15
|
]);
|
|
15
16
|
|
|
16
17
|
// 16MB: tool results carrying base64 images legitimately exceed 1MB — the old
|
|
@@ -130,7 +131,9 @@ function providerErrorFlags(message) {
|
|
|
130
131
|
const text = String(message || "").toLowerCase();
|
|
131
132
|
return {
|
|
132
133
|
authError: /unauthori[sz]ed|not (?:logged in|authenticated)|unauthenticated|login required|authentication failed|auth failed|invalid api key|no credentials|401/.test(text),
|
|
133
|
-
|
|
134
|
+
// "You've hit your limit / session limit / weekly limit / Opus limit · resets …"
|
|
135
|
+
// is how Claude Code words a plan limit; the older patterns never matched it.
|
|
136
|
+
usageLimit: /usage limit|spend limit|monthly cycle|quota|rate limit|hit your (?:[a-z0-9'’-]+ ){0,3}limit/.test(text),
|
|
134
137
|
refusal: /refusals-and-fallback|usage policy|safety classifier|stop_reason['":\s]*refusal/.test(text),
|
|
135
138
|
};
|
|
136
139
|
}
|
package/core/router.js
CHANGED
|
@@ -167,6 +167,16 @@ async function onMessage(envelope) {
|
|
|
167
167
|
// and the meet-greet lane all have a person to attach to.
|
|
168
168
|
try { require("./relationship").ensureContact(envelope); } catch (e) {}
|
|
169
169
|
|
|
170
|
+
// The person writing again drops their pick-up of a message a usage
|
|
171
|
+
// limit stopped (core/limit-pickup.js): the conversation moved on. Also
|
|
172
|
+
// when the guard then holds this message. Another bot's message does not.
|
|
173
|
+
if (!envelope.botHandoff) {
|
|
174
|
+
try {
|
|
175
|
+
const state = currentState();
|
|
176
|
+
require("./limit-pickup").cancel(state.userId, require("./state").currentLaneKey(state), "a new message");
|
|
177
|
+
} catch (e) {}
|
|
178
|
+
}
|
|
179
|
+
|
|
170
180
|
if (envelope.type === "voice") return handleVoice(envelope);
|
|
171
181
|
if (envelope.type === "audio") return handleAudio(envelope);
|
|
172
182
|
if (envelope.type === "photo") return handlePhoto(envelope);
|
package/core/run-context.js
CHANGED
|
@@ -213,6 +213,9 @@ function captureRunContext({
|
|
|
213
213
|
occurrenceId: opts.occurrenceId || null,
|
|
214
214
|
scheduledJobId: opts.scheduledJobId || null,
|
|
215
215
|
originRunId: opts.originRunId || null,
|
|
216
|
+
// Set on a turn that core/limit-pickup.js re-runs after a usage limit
|
|
217
|
+
// reset: { originalPrompt, firstHitAt, attempts }.
|
|
218
|
+
limitPickup: opts.limitPickup ? cloneSerializable(opts.limitPickup, "limit pickup") : null,
|
|
216
219
|
};
|
|
217
220
|
|
|
218
221
|
return deepFreeze(context);
|
package/core/runner.js
CHANGED
|
@@ -16,12 +16,14 @@ const {
|
|
|
16
16
|
currentState, saveState, recordSession,
|
|
17
17
|
resetSessionUsage, admitLane, cancelTargets, getActiveProvider, getProjectKey,
|
|
18
18
|
laneFor, laneForRun, peekLane, laneBusy, runLaneKey, waitingLanes, slotBlocker, pruneIdleLanes,
|
|
19
|
-
getProviderSession, setProviderSession, clearProviderSession, quarantineProviderSession, getUsageForSession, commitProviderCompaction,
|
|
19
|
+
getProviderSession, setProviderSession, clearProviderSession, quarantineProviderSession, getUsageForSession, commitProviderCompaction, commitBranchCompaction,
|
|
20
20
|
resolveProviderSettings, getProjectSessions,
|
|
21
21
|
} = require("./state");
|
|
22
22
|
const { chatContext, currentChannelId, currentAdapter, currentThreadId } = require("./context");
|
|
23
23
|
const { buildTurnPrompt } = require("./system-prompt");
|
|
24
24
|
const turnJournal = require("./turn-journal");
|
|
25
|
+
const usageLimits = require("./usage-limits");
|
|
26
|
+
const limitPickup = require("./limit-pickup");
|
|
25
27
|
const { getProvider, resolveProviderForRun } = require("./providers");
|
|
26
28
|
const { validateInvocation } = require("./providers/contract");
|
|
27
29
|
const { selectTerminalText } = require("./providers/events");
|
|
@@ -318,6 +320,8 @@ async function executeProviderInvocation(options = {}) {
|
|
|
318
320
|
const cleanupOutcome = { ok: true, skipped: false };
|
|
319
321
|
const rawErrors = [];
|
|
320
322
|
const usageEvents = [];
|
|
323
|
+
// The provider's last plan-limit reading this run (Claude's rate_limit_event).
|
|
324
|
+
let rateLimit = null;
|
|
321
325
|
const tools = [];
|
|
322
326
|
const textDeltas = [];
|
|
323
327
|
const textFinals = [];
|
|
@@ -403,6 +407,7 @@ async function executeProviderInvocation(options = {}) {
|
|
|
403
407
|
tools.push(event);
|
|
404
408
|
}
|
|
405
409
|
else if (event.type === "usage") usageEvents.push(event);
|
|
410
|
+
else if (event.type === "rate_limit") rateLimit = event;
|
|
406
411
|
else if (event.type === "result") {
|
|
407
412
|
terminalSeen = true;
|
|
408
413
|
if (event.sessionId) sessionId = event.sessionId;
|
|
@@ -598,6 +603,7 @@ async function executeProviderInvocation(options = {}) {
|
|
|
598
603
|
usage: finalUsage?.usage || null,
|
|
599
604
|
usageScope: finalUsage?.scope || null,
|
|
600
605
|
usageEvents,
|
|
606
|
+
rateLimit,
|
|
601
607
|
costUsd: Number.isFinite(invocation.parserState.totalCostUsd) ? invocation.parserState.totalCostUsd : 0,
|
|
602
608
|
tools,
|
|
603
609
|
exitCode: closeInfo.code,
|
|
@@ -1084,7 +1090,12 @@ function coldResumeDecision(state, runContext, opts = {}) {
|
|
|
1084
1090
|
if (runContext.fresh || runContext.continueSession || runContext.liveCall) return no("fresh-or-continue");
|
|
1085
1091
|
const provider = runContext.provider;
|
|
1086
1092
|
const sessionId = runContext.sessionId || null;
|
|
1087
|
-
if (!sessionId
|
|
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");
|
|
1088
1099
|
if (peekLane(state, opts.laneKey || runLaneKey(runContext))?.isCompacting) return no("compacting");
|
|
1089
1100
|
if (effectiveCompactThreshold(state, { conversationKey: key, tokenDiet: opts.tokenDiet }) === 0) return no("compaction-off");
|
|
1090
1101
|
const settings = tokenDietSettings(opts.tokenDiet);
|
|
@@ -1099,7 +1110,44 @@ function coldResumeDecision(state, runContext, opts = {}) {
|
|
|
1099
1110
|
const checkpointTime = Date.parse(usage?.compactionCheckpoint?.at || "");
|
|
1100
1111
|
const lastCompactedAt = Number(usage?.lastCompactedAt) || (Number.isFinite(checkpointTime) ? checkpointTime : 0);
|
|
1101
1112
|
if (lastCompactedAt && now - lastCompactedAt < minInterval) return no("recently-compacted", { idleMinutes, contextTokens });
|
|
1102
|
-
return { compact: true, reason: "cold-resume", key, 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 };
|
|
1103
1151
|
}
|
|
1104
1152
|
|
|
1105
1153
|
// Runs the cold-resume compaction for a turn that holds its lane, and returns
|
|
@@ -1121,12 +1169,14 @@ async function compactColdResume(runContext, state, lane, deps = {}) {
|
|
|
1121
1169
|
}
|
|
1122
1170
|
return runContext;
|
|
1123
1171
|
}
|
|
1124
|
-
|
|
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)}` : ""}`);
|
|
1125
1174
|
let result;
|
|
1126
1175
|
try {
|
|
1127
1176
|
result = await compact(runContext.project.dir, {
|
|
1128
1177
|
notify: false, laneKey: lane.key, admissionHeld: true,
|
|
1129
1178
|
compactReason: "cold-resume", contextTokensBefore: decision.contextTokens,
|
|
1179
|
+
...(branch ? { sessionId: branch, provider: runContext.provider } : {}),
|
|
1130
1180
|
});
|
|
1131
1181
|
} catch (error) {
|
|
1132
1182
|
console.warn(`[token-diet] cold-resume compaction failed: ${redactSensitive(error.message)}`);
|
|
@@ -1136,6 +1186,18 @@ async function compactColdResume(runContext, state, lane, deps = {}) {
|
|
|
1136
1186
|
console.warn(`[token-diet] cold-resume compaction did not complete: ${result?.reason || "unknown"}`);
|
|
1137
1187
|
return runContext;
|
|
1138
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
|
+
}
|
|
1139
1201
|
return deepFreeze({
|
|
1140
1202
|
...runContext,
|
|
1141
1203
|
sessionId: result.newSessionId,
|
|
@@ -1173,6 +1235,8 @@ function recordCompactionUsage(entry, deps = {}) {
|
|
|
1173
1235
|
version: PKG_VERSION,
|
|
1174
1236
|
kind: "compaction",
|
|
1175
1237
|
compactReason: entry.compactReason || "manual",
|
|
1238
|
+
// A scheduled job's own conversation, not its thread's live session.
|
|
1239
|
+
...(entry.branch ? { scope: "job" } : {}),
|
|
1176
1240
|
backend: entry.provider,
|
|
1177
1241
|
provider: entry.provider,
|
|
1178
1242
|
model: entry.model || null,
|
|
@@ -1190,6 +1254,7 @@ function compactSummaryPrompt() {
|
|
|
1190
1254
|
return [
|
|
1191
1255
|
"Summarize this conversation for a fresh compacted continuation.",
|
|
1192
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.",
|
|
1193
1258
|
"",
|
|
1194
1259
|
"Include:",
|
|
1195
1260
|
"- Current user goal and any active side threads or sub-goals.",
|
|
@@ -1466,6 +1531,7 @@ function createCompactionService(dependencies = {}) {
|
|
|
1466
1531
|
const captureContext = dependencies.captureRunContext || admitRunContext;
|
|
1467
1532
|
const runCapture = dependencies.runCapture || runAgentCapture;
|
|
1468
1533
|
const commitCompaction = dependencies.commitCompaction || commitProviderCompaction;
|
|
1534
|
+
const commitBranch = dependencies.commitBranchCompaction || commitBranchCompaction;
|
|
1469
1535
|
const archiveBrief = dependencies.archiveBrief || archiveCompactionBrief;
|
|
1470
1536
|
const appendDaySeed = dependencies.appendDaySeed || ((entry) => require("./day-seeds").appendDaySeed(entry));
|
|
1471
1537
|
const collectRepoFacts = dependencies.collectRepoFacts || collectRepoStateFacts;
|
|
@@ -1484,9 +1550,14 @@ function createCompactionService(dependencies = {}) {
|
|
|
1484
1550
|
if (lane.runningProcess || ((lane.preparingRun || lane.settling) && !opts.admissionHeld)) {
|
|
1485
1551
|
return { compacted: false, reason: "Finish or stop the active run before compacting." };
|
|
1486
1552
|
}
|
|
1487
|
-
const provider = getActiveProvider(state);
|
|
1553
|
+
const provider = opts.provider || getActiveProvider(state);
|
|
1488
1554
|
const project = getProjectKey(state);
|
|
1489
|
-
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;
|
|
1490
1561
|
if (!project || !oldSessionId) return { compacted: false, reason: "No conversation." };
|
|
1491
1562
|
const summaryPrompt = compactSummaryPrompt();
|
|
1492
1563
|
const admitted = captureContext(summaryPrompt, cwd, null, {
|
|
@@ -1511,7 +1582,7 @@ function createCompactionService(dependencies = {}) {
|
|
|
1511
1582
|
// The recent turns come from THIS conversation's transcript: the captured
|
|
1512
1583
|
// project, not the state's legacy "Workspace" session (which read another file).
|
|
1513
1584
|
const recentTurns = collectRecent(capturedStateView(state, summaryContext));
|
|
1514
|
-
const usageBefore = state.sessionUsage;
|
|
1585
|
+
const usageBefore = branch ? getUsageForSession(state, project, provider, oldSessionId) : state.sessionUsage;
|
|
1515
1586
|
const contextTokensBefore = Number.isFinite(opts.contextTokensBefore)
|
|
1516
1587
|
? opts.contextTokensBefore
|
|
1517
1588
|
: (usageBefore?.liveContextTokens || usageBefore?.lastInputTokens || 0);
|
|
@@ -1566,14 +1637,14 @@ function createCompactionService(dependencies = {}) {
|
|
|
1566
1637
|
}
|
|
1567
1638
|
let commit;
|
|
1568
1639
|
try {
|
|
1569
|
-
commit = await commitCompaction(state, {
|
|
1640
|
+
commit = await (branch ? commitBranch : commitCompaction)(state, {
|
|
1570
1641
|
operationId,
|
|
1571
1642
|
canonicalUserId: summaryContext.canonicalUserId,
|
|
1572
1643
|
project: summaryContext.project.name,
|
|
1573
1644
|
provider: summaryContext.provider,
|
|
1574
1645
|
previousSessionId: oldSessionId,
|
|
1575
1646
|
newSessionId: seedRun.sessionId,
|
|
1576
|
-
title: `Compacted ${new Date().toLocaleDateString()}`,
|
|
1647
|
+
title: branch ? null : `Compacted ${new Date().toLocaleDateString()}`,
|
|
1577
1648
|
seedUsage: seedRun.usage || null,
|
|
1578
1649
|
});
|
|
1579
1650
|
} catch (error) {
|
|
@@ -1600,6 +1671,7 @@ function createCompactionService(dependencies = {}) {
|
|
|
1600
1671
|
summary,
|
|
1601
1672
|
commit,
|
|
1602
1673
|
compactReason: opts.compactReason || "manual",
|
|
1674
|
+
...(branch ? { branch: true } : {}),
|
|
1603
1675
|
};
|
|
1604
1676
|
} finally {
|
|
1605
1677
|
lane.isCompacting = false;
|
|
@@ -1607,6 +1679,7 @@ function createCompactionService(dependencies = {}) {
|
|
|
1607
1679
|
try {
|
|
1608
1680
|
recordCompaction({
|
|
1609
1681
|
compactReason: opts.compactReason || "manual",
|
|
1682
|
+
branch,
|
|
1610
1683
|
provider: summaryContext.provider,
|
|
1611
1684
|
model: summaryContext.providerSettings?.model || null,
|
|
1612
1685
|
userId: summaryContext.canonicalUserId,
|
|
@@ -1662,6 +1735,58 @@ function capturedTranscriptMetadata(runContext, extra = {}) {
|
|
|
1662
1735
|
};
|
|
1663
1736
|
}
|
|
1664
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
|
+
|
|
1665
1790
|
function providerRunDiagnostic(result, runContext) {
|
|
1666
1791
|
if (result.ok) return null;
|
|
1667
1792
|
let provider;
|
|
@@ -1674,6 +1799,8 @@ function providerRunDiagnostic(result, runContext) {
|
|
|
1674
1799
|
return `${label} authentication failed.${help ? `\n\n${help}` : ""}`;
|
|
1675
1800
|
}
|
|
1676
1801
|
if (code === "PROVIDER_USAGE_LIMIT") {
|
|
1802
|
+
const plan = limitPlanFor(result, runContext);
|
|
1803
|
+
if (plan?.notice) return plan.notice;
|
|
1677
1804
|
return `${label} reported a usage or rate limit. Check that provider's account status or select another configured provider.`;
|
|
1678
1805
|
}
|
|
1679
1806
|
if (code === "PROVIDER_REFUSAL") {
|
|
@@ -1777,6 +1904,7 @@ async function persistForegroundUsage(result, runContext, state = currentState()
|
|
|
1777
1904
|
liveContextTokens = rollout && rollout.lastContextTokens > 0
|
|
1778
1905
|
? rollout.lastContextTokens
|
|
1779
1906
|
: usageParts(rawUsage, "codex").context;
|
|
1907
|
+
keepOnResult(result, "codexRateLimits", rollout ? rollout.rateLimits || null : null);
|
|
1780
1908
|
}
|
|
1781
1909
|
const rolloutOpts = rollout ? {
|
|
1782
1910
|
contextSource: "rollout_last_request",
|
|
@@ -1902,6 +2030,20 @@ async function finalizeStreamPreview(lane, result, runContext = null) {
|
|
|
1902
2030
|
// words. That is never an answer, so it is never posted.
|
|
1903
2031
|
const PLACEHOLDER_REPLY = /^no response requested\.?$/i;
|
|
1904
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
|
+
|
|
1905
2047
|
async function deliverForegroundResult(result, runContext, state = currentState()) {
|
|
1906
2048
|
const lane = laneForRun(state, runContext);
|
|
1907
2049
|
if (runContext.lastInputWasVoice) lane.lastInputWasVoice = false;
|
|
@@ -1915,6 +2057,17 @@ async function deliverForegroundResult(result, runContext, state = currentState(
|
|
|
1915
2057
|
if (stoppedHere) lane.cancelRequested = false;
|
|
1916
2058
|
return { ok: true, suppressed: "cancelled" };
|
|
1917
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" };
|
|
1918
2071
|
const diagnostic = providerRunDiagnostic(result, runContext);
|
|
1919
2072
|
if (diagnostic) result.diagnostic = diagnostic;
|
|
1920
2073
|
const cleanAnswer = String(result.finalAnswerText || "").trim();
|
|
@@ -1966,6 +2119,7 @@ async function deliverForegroundResult(result, runContext, state = currentState(
|
|
|
1966
2119
|
if (!sent.ok) sent = await send(chunks[index], telegramHtmlOpts({ botTags: true }));
|
|
1967
2120
|
if (!sent.ok) throw new Error(`failed to deliver response chunk ${index + 1}`);
|
|
1968
2121
|
}
|
|
2122
|
+
if (limitWarnings.length) await sendLimitWarnings(limitWarnings);
|
|
1969
2123
|
|
|
1970
2124
|
const shouldSpeak = shouldReplyWithVoice(VOICE_REPLY_MODE, runContext.lastInputWasVoice);
|
|
1971
2125
|
if (result.ok && shouldSpeak) {
|
|
@@ -2712,10 +2866,23 @@ async function runAdmitted(runContext, state, store, capture, ownsAdmissionLock,
|
|
|
2712
2866
|
// kill keeps it journaled; see turn-journal.end.)
|
|
2713
2867
|
if (tracksMainRun) turnJournal.end(runContext);
|
|
2714
2868
|
// Auto-compaction is part of the lane's turn: only when nothing waits in
|
|
2715
|
-
// this conversation, and it compacts this conversation only.
|
|
2716
|
-
|
|
2717
|
-
|
|
2718
|
-
|
|
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
|
+
}
|
|
2719
2886
|
}
|
|
2720
2887
|
return result;
|
|
2721
2888
|
}
|
|
@@ -2798,6 +2965,11 @@ function runAgent(prompt, cwd, replyToMsgId, opts = {}) {
|
|
|
2798
2965
|
});
|
|
2799
2966
|
|
|
2800
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
|
+
}
|
|
2801
2973
|
// A planned restart is draining: no new turn starts now. The message is
|
|
2802
2974
|
// journaled as queued and runs right after the restart.
|
|
2803
2975
|
if (turnJournal.isDraining() && turnJournal.journalable(runContext)) {
|
|
@@ -2975,6 +3147,7 @@ module.exports = {
|
|
|
2975
3147
|
defaultCompactTokens,
|
|
2976
3148
|
coldResumeDecision,
|
|
2977
3149
|
compactColdResume,
|
|
3150
|
+
jobBranchThresholdDecision,
|
|
2978
3151
|
recordCompactionUsage,
|
|
2979
3152
|
effectiveCompactThreshold,
|
|
2980
3153
|
usageParts,
|
package/core/scheduler.js
CHANGED
|
@@ -33,6 +33,7 @@ const {
|
|
|
33
33
|
getProjectKey,
|
|
34
34
|
getProviderSession,
|
|
35
35
|
getProviderSettings,
|
|
36
|
+
getProjectSessions,
|
|
36
37
|
getUserState,
|
|
37
38
|
peekLane,
|
|
38
39
|
laneBusy,
|
|
@@ -247,6 +248,7 @@ function createSchedulerService(dependencies = {}) {
|
|
|
247
248
|
: dependencies.legacyCronsFile;
|
|
248
249
|
const runInProvider = dependencies.runAgent || ((...args) => require("./runner").runAgent(...args));
|
|
249
250
|
const admit = dependencies.admitRunContext || ((...args) => require("./runner").admitRunContext(...args));
|
|
251
|
+
const projectSessions = dependencies.getProjectSessions || getProjectSessions;
|
|
250
252
|
const timers = new Map();
|
|
251
253
|
const checksInFlight = new Set();
|
|
252
254
|
const firesInFlight = new Set();
|
|
@@ -335,6 +337,26 @@ function createSchedulerService(dependencies = {}) {
|
|
|
335
337
|
return getProviderSession(state, job.provider, roomKey) || null;
|
|
336
338
|
}
|
|
337
339
|
|
|
340
|
+
// previous session -> the session it was compacted into, from the
|
|
341
|
+
// conversation's session history (each compaction's lineage is kept on the
|
|
342
|
+
// new session's record). The completed-compactions index holds only the
|
|
343
|
+
// last 20, under a day of token-diet compactions across every thread, so a
|
|
344
|
+
// job made before them lost its way: 7 Oct the PBX thread's wakeup and
|
|
345
|
+
// watch resumed a 790k conversation compacted that morning ($86).
|
|
346
|
+
function historySuccessors(state, job) {
|
|
347
|
+
const successors = new Map();
|
|
348
|
+
let records = [];
|
|
349
|
+
try { records = projectSessions(state?.userId || job.canonicalUserId, job.project, job.provider) || []; }
|
|
350
|
+
catch (_) { records = []; }
|
|
351
|
+
// Newest first: if a session was ever compacted twice, the latest wins.
|
|
352
|
+
for (const record of records) {
|
|
353
|
+
const lineage = record?.lineage;
|
|
354
|
+
if (lineage?.type !== "compaction" || !lineage.previousSessionId || !lineage.newSessionId) continue;
|
|
355
|
+
if (!successors.has(lineage.previousSessionId)) successors.set(lineage.previousSessionId, lineage.newSessionId);
|
|
356
|
+
}
|
|
357
|
+
return successors;
|
|
358
|
+
}
|
|
359
|
+
|
|
338
360
|
function resolveCompactionLineage(state, job) {
|
|
339
361
|
let sessionId = job.sessionId || null;
|
|
340
362
|
// A thread job never resumes its room's MAIN session: a job made in a
|
|
@@ -347,6 +369,7 @@ function createSchedulerService(dependencies = {}) {
|
|
|
347
369
|
sessionId = own && own !== mainSession ? own : null;
|
|
348
370
|
}
|
|
349
371
|
if (!sessionId) return { sessionId: null, attachSession: true };
|
|
372
|
+
let recorded = null;
|
|
350
373
|
const seen = new Set();
|
|
351
374
|
while (sessionId && !seen.has(sessionId)) {
|
|
352
375
|
seen.add(sessionId);
|
|
@@ -355,9 +378,9 @@ function createSchedulerService(dependencies = {}) {
|
|
|
355
378
|
&& entry?.project === job.project
|
|
356
379
|
&& entry?.previousSessionId === sessionId
|
|
357
380
|
&& entry?.newSessionId
|
|
358
|
-
));
|
|
381
|
+
))?.newSessionId || (recorded ||= historySuccessors(state, job)).get(sessionId);
|
|
359
382
|
if (!successor) break;
|
|
360
|
-
sessionId = successor
|
|
383
|
+
sessionId = successor;
|
|
361
384
|
}
|
|
362
385
|
const live = getProviderSession(state, job.provider, job.project);
|
|
363
386
|
return {
|
|
@@ -694,6 +717,14 @@ function createSchedulerService(dependencies = {}) {
|
|
|
694
717
|
lastProvider: target.provider,
|
|
695
718
|
fallbackFromProvider: target.fallbackFrom,
|
|
696
719
|
};
|
|
720
|
+
// A recurring job keeps its own conversation: remember the session this
|
|
721
|
+
// run resumed and ended on (lineage or a compaction may have moved it), so
|
|
722
|
+
// the next run starts there even after the lineage records age out. Only
|
|
723
|
+
// for a run that resumed the job's conversation under its own provider.
|
|
724
|
+
const ranOn = typeof result?.sessionId === "string" && result.sessionId ? result.sessionId : null;
|
|
725
|
+
if (!isOneShot(job) && ranOn && ranOn !== job.sessionId && target.sessionId && !target.fresh && target.provider === job.provider) {
|
|
726
|
+
successPatch.sessionId = ranOn;
|
|
727
|
+
}
|
|
697
728
|
let marked;
|
|
698
729
|
try { marked = jobsStore.update(job.id, successPatch); }
|
|
699
730
|
catch (error) { return { ok: false, status: "failed", error: stableFailure(error), result }; }
|
package/core/state.js
CHANGED
|
@@ -1671,6 +1671,96 @@ function commitProviderCompaction(state, transaction, options = {}) {
|
|
|
1671
1671
|
}
|
|
1672
1672
|
}
|
|
1673
1673
|
|
|
1674
|
+
// A scheduled job resumes its own conversation: a branch beside its thread's
|
|
1675
|
+
// live session once the thread has moved on. Compacting that branch records
|
|
1676
|
+
// the lineage (on the new session's history record and in the completed-
|
|
1677
|
+
// compactions index) so the job moves on to the new session, but never
|
|
1678
|
+
// touches the thread's live pointer: the thread is not the job's conversation.
|
|
1679
|
+
function commitBranchCompaction(state, transaction, options = {}) {
|
|
1680
|
+
const tx = validateCompactionTransaction(state, transaction);
|
|
1681
|
+
if (state.completedCompactions?.[tx.operationId]) {
|
|
1682
|
+
return {
|
|
1683
|
+
committed: true,
|
|
1684
|
+
duplicate: true,
|
|
1685
|
+
operationId: tx.operationId,
|
|
1686
|
+
sessionId: state.completedCompactions[tx.operationId].newSessionId,
|
|
1687
|
+
};
|
|
1688
|
+
}
|
|
1689
|
+
if (getProviderSession(state, tx.provider, tx.project) === tx.previousSessionId) {
|
|
1690
|
+
throw compactionError("COMPACTION_POINTER_CHANGED", "the branch became the live session before its compaction commit");
|
|
1691
|
+
}
|
|
1692
|
+
const persistState = options.persistState || saveState;
|
|
1693
|
+
const recordHistory = options.recordHistory || recordSession;
|
|
1694
|
+
const nowValue = typeof options.now === "function" ? options.now() : new Date();
|
|
1695
|
+
const now = nowValue instanceof Date ? nowValue : new Date(nowValue);
|
|
1696
|
+
if (!Number.isFinite(now.getTime())) throw compactionError("INVALID_COMPACTION", "compaction clock is invalid");
|
|
1697
|
+
requireCompactionWrite(
|
|
1698
|
+
recordHistory(
|
|
1699
|
+
tx.canonicalUserId,
|
|
1700
|
+
tx.project,
|
|
1701
|
+
tx.provider,
|
|
1702
|
+
tx.newSessionId,
|
|
1703
|
+
tx.title || `Scheduled job conversation, compacted ${now.toLocaleDateString()}`,
|
|
1704
|
+
{
|
|
1705
|
+
strict: true,
|
|
1706
|
+
now: () => now,
|
|
1707
|
+
lineage: {
|
|
1708
|
+
type: "compaction",
|
|
1709
|
+
operationId: tx.operationId,
|
|
1710
|
+
previousSessionId: tx.previousSessionId,
|
|
1711
|
+
newSessionId: tx.newSessionId,
|
|
1712
|
+
branch: true,
|
|
1713
|
+
},
|
|
1714
|
+
},
|
|
1715
|
+
),
|
|
1716
|
+
"branch compaction history",
|
|
1717
|
+
);
|
|
1718
|
+
const newUsageKey = usageSessionKey(tx.project, tx.provider, tx.newSessionId);
|
|
1719
|
+
if (!isRecord(state.usageBySession)) state.usageBySession = {};
|
|
1720
|
+
const previousUsageBySession = state.usageBySession;
|
|
1721
|
+
const previousCompleted = state.completedCompactions;
|
|
1722
|
+
const nextUsage = {
|
|
1723
|
+
...freshUsage(),
|
|
1724
|
+
lastCompactedAt: now.getTime(),
|
|
1725
|
+
compactionCheckpoint: {
|
|
1726
|
+
operationId: tx.operationId,
|
|
1727
|
+
previousSessionId: tx.previousSessionId,
|
|
1728
|
+
newSessionId: tx.newSessionId,
|
|
1729
|
+
at: now.toISOString(),
|
|
1730
|
+
branch: true,
|
|
1731
|
+
},
|
|
1732
|
+
};
|
|
1733
|
+
if (tx.provider === "codex" && isRecord(tx.seedUsage)) {
|
|
1734
|
+
nextUsage.providerUsageSnapshot = {
|
|
1735
|
+
input_tokens: Number(tx.seedUsage.input_tokens) || 0,
|
|
1736
|
+
cached_input_tokens: Number(tx.seedUsage.cached_input_tokens) || 0,
|
|
1737
|
+
output_tokens: Number(tx.seedUsage.output_tokens) || 0,
|
|
1738
|
+
};
|
|
1739
|
+
}
|
|
1740
|
+
state.usageBySession = boundUsageBySession({ ...previousUsageBySession, [newUsageKey]: nextUsage });
|
|
1741
|
+
state.completedCompactions = Object.fromEntries(Object.entries({
|
|
1742
|
+
...cloneRecord(previousCompleted),
|
|
1743
|
+
[tx.operationId]: {
|
|
1744
|
+
project: tx.project,
|
|
1745
|
+
provider: tx.provider,
|
|
1746
|
+
previousSessionId: tx.previousSessionId,
|
|
1747
|
+
newSessionId: tx.newSessionId,
|
|
1748
|
+
committedAt: now.toISOString(),
|
|
1749
|
+
branch: true,
|
|
1750
|
+
},
|
|
1751
|
+
}).slice(-20));
|
|
1752
|
+
try {
|
|
1753
|
+
requireCompactionWrite(persistState({ strict: true }), "branch compaction state");
|
|
1754
|
+
} catch (error) {
|
|
1755
|
+
// The history record stays: the new session IS the branch's compacted
|
|
1756
|
+
// continuation, and the scheduler follows it from there.
|
|
1757
|
+
state.usageBySession = previousUsageBySession;
|
|
1758
|
+
state.completedCompactions = previousCompleted;
|
|
1759
|
+
throw error;
|
|
1760
|
+
}
|
|
1761
|
+
return { committed: true, duplicate: false, branch: true, operationId: tx.operationId, sessionId: tx.newSessionId };
|
|
1762
|
+
}
|
|
1763
|
+
|
|
1674
1764
|
function migrateUserData(fromUserId, toUserId) {
|
|
1675
1765
|
const from = normalizeCanonicalUserId(fromUserId);
|
|
1676
1766
|
const to = normalizeCanonicalUserId(toUserId);
|
|
@@ -1828,6 +1918,7 @@ module.exports = {
|
|
|
1828
1918
|
createSessionCallbackToken,
|
|
1829
1919
|
consumeSessionCallbackToken,
|
|
1830
1920
|
commitProviderCompaction,
|
|
1921
|
+
commitBranchCompaction,
|
|
1831
1922
|
recoverPendingCompaction,
|
|
1832
1923
|
migrateUserData,
|
|
1833
1924
|
linkIdentity,
|
package/core/turn-journal.js
CHANGED
|
@@ -498,6 +498,8 @@ function _resetForTests() {
|
|
|
498
498
|
|
|
499
499
|
module.exports = {
|
|
500
500
|
journalable, begin, end, enqueue, dropQueued, beat, list, fileFor,
|
|
501
|
+
// Shared with core/limit-pickup.js, which re-runs a message the same way.
|
|
502
|
+
conversationOf, chatScope, findAdapter, defaultDeps,
|
|
501
503
|
resumeInterrupted, handleResumeAction, RESUME_PROMPT,
|
|
502
504
|
rememberDropped, droppedKeyboard, handleRunDropped,
|
|
503
505
|
drain, exitAfterDrain, isDraining, markShuttingDown,
|