@inetafrica/open-claudia 3.5.17 → 3.5.19
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 +27 -0
- package/README.md +3 -0
- package/bot.js +7 -0
- package/core/config.js +9 -6
- package/core/dedup-adjudicator.js +42 -19
- 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/recall-filter.js +28 -9
- 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/core/utility-agent.js +7 -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 +14 -1
- package/test-provider-recall.js +38 -5
- package/test-provider-scheduler.js +69 -0
- package/test-usage-accounting.js +4 -0
- package/test-utility-provider-policy.js +27 -0
|
@@ -16,9 +16,56 @@ function createClaudeParserState() {
|
|
|
16
16
|
seenUsage: new Set(),
|
|
17
17
|
totalCostUsd: 0,
|
|
18
18
|
refusal: null,
|
|
19
|
+
// Set once the plan limit rejected this run: { resetsAt, limitType, text }.
|
|
20
|
+
limitHit: null,
|
|
19
21
|
};
|
|
20
22
|
}
|
|
21
23
|
|
|
24
|
+
const epochMs = (seconds) => {
|
|
25
|
+
const n = Number(seconds);
|
|
26
|
+
return Number.isFinite(n) && n > 0 ? Math.round(n * 1000) : null;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// Claude Code's rate_limit_event (emitted when the plan status changes, so at
|
|
30
|
+
// least once per fresh run). rate_limit_info.unifiedWindows carries the use of
|
|
31
|
+
// each window as a 0-1 fraction; status "allowed_warning" is Claude Code's own
|
|
32
|
+
// pace warning (it fires at 25% early in the week), so callers should read the
|
|
33
|
+
// windows, not the status, for "close to the limit".
|
|
34
|
+
function claudeRateLimitEvent(info) {
|
|
35
|
+
if (!info || typeof info !== "object") return null;
|
|
36
|
+
const windows = [];
|
|
37
|
+
const unified = info.unifiedWindows && typeof info.unifiedWindows === "object" ? info.unifiedWindows : {};
|
|
38
|
+
for (const [id, window] of Object.entries(unified)) {
|
|
39
|
+
const utilization = Number(window?.utilization);
|
|
40
|
+
if (!Number.isFinite(utilization)) continue;
|
|
41
|
+
windows.push({ id, usedPercent: Math.round(utilization * 1000) / 10, resetsAt: epochMs(window.resetsAt) });
|
|
42
|
+
}
|
|
43
|
+
return {
|
|
44
|
+
type: "rate_limit",
|
|
45
|
+
provider: "claude",
|
|
46
|
+
status: typeof info.status === "string" ? info.status : null,
|
|
47
|
+
limitType: typeof info.rateLimitType === "string" ? info.rateLimitType : null,
|
|
48
|
+
resetsAt: epochMs(info.resetsAt),
|
|
49
|
+
windows,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function noteLimitHit(state, patch) {
|
|
54
|
+
state.limitHit = { resetsAt: null, limitType: null, text: null, ...(state.limitHit || {}) };
|
|
55
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
56
|
+
if (value !== null && value !== undefined && value !== "") state.limitHit[key] = value;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// The limit notice arrives as a synthetic assistant message (model
|
|
61
|
+
// "<synthetic>", error "rate_limit"). It is not answer text.
|
|
62
|
+
function isLimitNotice(rawEvent) {
|
|
63
|
+
if (rawEvent.error === "rate_limit") return true;
|
|
64
|
+
if (rawEvent.message?.model !== "<synthetic>") return false;
|
|
65
|
+
const text = (rawEvent.message?.content || []).map((block) => (block?.type === "text" ? block.text : "")).join(" ");
|
|
66
|
+
return providerErrorFlags(text).usageLimit;
|
|
67
|
+
}
|
|
68
|
+
|
|
22
69
|
function sessionEvent(sessionId, state) {
|
|
23
70
|
if (!sessionId || state.sessionId === sessionId) return null;
|
|
24
71
|
state.sessionId = sessionId;
|
|
@@ -43,6 +90,11 @@ function captureRefusal(state, stopReason, stopDetails) {
|
|
|
43
90
|
function claudeFailureEvent(message, state) {
|
|
44
91
|
const flags = providerErrorFlags(message);
|
|
45
92
|
const event = { type: "error", message, ...flags };
|
|
93
|
+
if (state.limitHit) {
|
|
94
|
+
event.usageLimit = true;
|
|
95
|
+
event.limitResetsAt = state.limitHit.resetsAt;
|
|
96
|
+
event.limitType = state.limitHit.limitType;
|
|
97
|
+
}
|
|
46
98
|
if (flags.refusal || state.refusal) {
|
|
47
99
|
const parsed = refusalDetailsFromMessage(message);
|
|
48
100
|
event.refusal = true;
|
|
@@ -72,6 +124,13 @@ function normalizeClaudeEvent(rawEvent, state = createClaudeParserState()) {
|
|
|
72
124
|
return event ? [event] : [];
|
|
73
125
|
}
|
|
74
126
|
|
|
127
|
+
if (rawEvent.type === "rate_limit_event") {
|
|
128
|
+
const event = claudeRateLimitEvent(rawEvent.rate_limit_info);
|
|
129
|
+
if (!event) return [];
|
|
130
|
+
if (event.status === "rejected") noteLimitHit(state, { resetsAt: event.resetsAt, limitType: event.limitType });
|
|
131
|
+
return [event];
|
|
132
|
+
}
|
|
133
|
+
|
|
75
134
|
if (rawEvent.type === "stream_event") {
|
|
76
135
|
if (rawEvent.event?.type === "message_delta") {
|
|
77
136
|
captureRefusal(state, rawEvent.event.delta?.stop_reason, rawEvent.event.delta?.stop_details ?? rawEvent.event.stop_details);
|
|
@@ -85,6 +144,11 @@ function normalizeClaudeEvent(rawEvent, state = createClaudeParserState()) {
|
|
|
85
144
|
}
|
|
86
145
|
|
|
87
146
|
if (rawEvent.type === "assistant") {
|
|
147
|
+
if (isLimitNotice(rawEvent)) {
|
|
148
|
+
const text = (rawEvent.message?.content || []).map((block) => (block?.type === "text" ? block.text : "")).join(" ").trim();
|
|
149
|
+
noteLimitHit(state, { text });
|
|
150
|
+
return [];
|
|
151
|
+
}
|
|
88
152
|
const events = [];
|
|
89
153
|
captureRefusal(state, rawEvent.message?.stop_reason, rawEvent.message?.stop_details);
|
|
90
154
|
for (const block of rawEvent.message?.content || []) {
|
|
@@ -133,6 +197,12 @@ function normalizeClaudeEvent(rawEvent, state = createClaudeParserState()) {
|
|
|
133
197
|
if (rawEvent.is_error || /^error/.test(String(rawEvent.subtype || ""))) {
|
|
134
198
|
return [claudeFailureEvent(claudeErrorMessage(rawEvent), state)];
|
|
135
199
|
}
|
|
200
|
+
if (state.limitHit && !state.sawText) {
|
|
201
|
+
// A plan limit is a failed turn even when the CLI closes it as a
|
|
202
|
+
// "success": the limit notice must never go out as the reply. (With
|
|
203
|
+
// answer text, Claude Code fell back to another model and finished.)
|
|
204
|
+
return [claudeFailureEvent(state.limitHit.text || claudeErrorMessage(rawEvent), state)];
|
|
205
|
+
}
|
|
136
206
|
const events = [];
|
|
137
207
|
const session = sessionEvent(rawEvent.session_id, state);
|
|
138
208
|
if (session) events.push(session);
|
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/recall-filter.js
CHANGED
|
@@ -7,9 +7,15 @@
|
|
|
7
7
|
const FILTER_TIMEOUT_MS = Number(process.env.RECALL_FILTER_TIMEOUT_MS || 20000);
|
|
8
8
|
const MESSAGE_CLIP_CHARS = 1500;
|
|
9
9
|
const CONTEXT_CLIP_CHARS = 3500;
|
|
10
|
+
// The root must be an object: Claude Code turns the schema into a tool whose
|
|
11
|
+
// input_schema the API requires to be an object, so the bare array this used
|
|
12
|
+
// to be got a 400 on every Claude call and the filter failed open.
|
|
10
13
|
const FILTER_OUTPUT_SCHEMA = Object.freeze({
|
|
11
|
-
type: "
|
|
12
|
-
|
|
14
|
+
type: "object",
|
|
15
|
+
required: ["relevant"],
|
|
16
|
+
properties: {
|
|
17
|
+
relevant: { type: "array", items: { type: "string" } },
|
|
18
|
+
},
|
|
13
19
|
});
|
|
14
20
|
|
|
15
21
|
function enabled() {
|
|
@@ -18,15 +24,28 @@ function enabled() {
|
|
|
18
24
|
|
|
19
25
|
const FILTER_SYSTEM_PROMPT = [
|
|
20
26
|
"You are a relevance judge for an assistant's memory recall.",
|
|
21
|
-
|
|
27
|
+
'Reply with ONLY a JSON object {"relevant": [candidate ids]} — no prose, no markdown fences.',
|
|
22
28
|
].join("\n");
|
|
23
29
|
|
|
30
|
+
// The reply is {"relevant": [...]}; a bare array is still read.
|
|
31
|
+
function idList(value) {
|
|
32
|
+
if (Array.isArray(value)) return value;
|
|
33
|
+
return value && typeof value === "object" && Array.isArray(value.relevant) ? value.relevant : null;
|
|
34
|
+
}
|
|
35
|
+
|
|
24
36
|
function parseIds(result) {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
const
|
|
29
|
-
|
|
37
|
+
const fromJson = idList(result?.json);
|
|
38
|
+
if (fromJson) return fromJson;
|
|
39
|
+
const text = String(result?.text || "");
|
|
40
|
+
for (const pattern of [/\{[\s\S]*\}/, /\[[\s\S]*?\]/]) {
|
|
41
|
+
const match = text.match(pattern);
|
|
42
|
+
if (!match) continue;
|
|
43
|
+
try {
|
|
44
|
+
const ids = idList(JSON.parse(match[0]));
|
|
45
|
+
if (ids) return ids;
|
|
46
|
+
} catch (_) { /* try the other shape */ }
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
30
49
|
}
|
|
31
50
|
|
|
32
51
|
function createRecallFilter(dependencies = {}) {
|
|
@@ -62,7 +81,7 @@ function createRecallFilter(dependencies = {}) {
|
|
|
62
81
|
"Candidates:",
|
|
63
82
|
...lines,
|
|
64
83
|
"",
|
|
65
|
-
`Reply with ONLY a JSON
|
|
84
|
+
`Reply with ONLY a JSON object of the relevant ids, e.g. {"relevant": ["${candidates[0].id}"]}. Use {"relevant": []} if none are relevant.`,
|
|
66
85
|
].join("\n");
|
|
67
86
|
try {
|
|
68
87
|
const result = await runUtility(prompt, {
|
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,
|