@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/.env.example
CHANGED
|
@@ -70,6 +70,16 @@ USAGE_ALERT_RATE_MULTIPLIER=1.75
|
|
|
70
70
|
USAGE_ALERT_BASELINE_TURNS=20
|
|
71
71
|
USAGE_ALERT_MIN_BASELINE_TURNS=6
|
|
72
72
|
USAGE_ALERT_COOLDOWN_MS=1800000
|
|
73
|
+
# Plan limits (Claude Pro/Max, the ChatGPT plan behind Codex). Warn in the chat
|
|
74
|
+
# when a window's use passes these percentages, once per window (off = never).
|
|
75
|
+
LIMIT_WARN_PERCENTS=80,95
|
|
76
|
+
# A message a usage limit stopped is picked up when the limit resets, unless
|
|
77
|
+
# the person writes again first: on (default) | off.
|
|
78
|
+
LIMIT_PICKUP=on
|
|
79
|
+
# Seconds after the reset before it runs (default 120); a reset further out
|
|
80
|
+
# than LIMIT_PICKUP_MAX_WAIT_H hours (default 192) is not waited for.
|
|
81
|
+
LIMIT_PICKUP_DELAY_S=120
|
|
82
|
+
LIMIT_PICKUP_MAX_WAIT_H=192
|
|
73
83
|
MEMORY_RECALL_MAX_CHARS=9000
|
|
74
84
|
# Default recall engine when a chat hasn't picked one with /engine: classic | discoverer
|
|
75
85
|
RECALL_ENGINE=classic
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 3.5.18
|
|
4
|
+
|
|
5
|
+
Usage limits (Sumeet, 7 Oct 15:00 EAT: "track usage and notify the user that they are going to hit their limit, if the limit is hit can the bot auto schedule to continue when the limit replenishes"; 15:14 EAT: "if the user sends it a message it cancels and if the session limit message comes up again it will just rearm").
|
|
6
|
+
|
|
7
|
+
- **A message a usage limit stopped is picked up when the limit resets.** When Claude's 5-hour or weekly limit (or Codex's plan limit) stops a chat turn, the bot says which limit and when it resets ("⏳ Claude's weekly limit is used up, so I stopped here. I'll pick your message back up on Fri 9 Oct at 08:02, when it resets. If you write to me before then, I'll answer that instead and drop this.") and runs the message again 2 minutes after the reset (`LIMIT_PICKUP_DELAY_S`), in the same conversation, as the person who sent it, with "check what you already did; never repeat a send, push, deploy or write". One pick-up per person per conversation, kept in `limit-pickups.json` so it survives a restart. The person writing again in that conversation drops it (also when the guard holds that message), as do `/stop`, `/new` and `/end`; another person in a group does not. A run that hits a limit again arms it again with the same message, at most 5 times. Resets further out than `LIMIT_PICKUP_MAX_WAIT_H` (192 h) are not waited for. `LIMIT_PICKUP=off` only says when the limit resets. An unasked group reply that hits a limit posts nothing.
|
|
8
|
+
- **The limit notice is never the reply.** Claude Code words a plan limit "You've hit your limit / session limit / weekly limit · resets …", which the old checks missed: the bot posted "Claude Code run failed: You've hit your session limit · resets 11:30am (Africa/Nairobi)", or the bare notice as if it were the answer (the tickets bot to a staff member, 7 Oct). The limit is now read from Claude Code's `rate_limit_event` (status `rejected`, the reset time) and its synthetic `rate_limit` message, which never streams as answer text; Codex's "You've hit your usage limit … try again at …" was already caught. The reset time comes from the event, else Codex's rollout, else the notice's own words.
|
|
9
|
+
- **Warnings at 80% and 95%.** Claude Code reports each window's use on every run (`rate_limit_event.unifiedWindows`, a 0-1 fraction per 5-hour and weekly window); Codex in its rollout (its windows are named by length: on a Pro account the main one is weekly). After the reply that takes a window past 80% or 95%, the chat gets "⚠️ Claude: 82% of the 5-hour limit is used. It resets at 17:20." Once per window per percentage (`LIMIT_WARN_PERCENTS`, `off` for none); never to an outside contact, on a call or for an unasked group reply. Claude Code's own `allowed_warning` status is a pace warning (25% early in the week) and is not used.
|
|
10
|
+
- **/usage shows the plan limits** ("Claude plan limits: 5-hour 57% (resets 17:20) · weekly 26% (resets Wed 14 Oct, 08:00)") and the waiting pick-up in this conversation.
|
|
11
|
+
- **Tests:** `test-usage-limits.js`, `test-limit-pickup.js` (new); `test-provider-events.js`; the fake agent CLI gains a `usage-limit` scenario and `FAKE_AGENT_RATE_LIMIT_INFO`.
|
|
12
|
+
|
|
13
|
+
Token diet for every bot (Sumeet, 8 Oct 08:19 EAT: "Let's roll it out to the fleet if we already seeing savings?"). On the owner's bot the 3.5.17 canary cut a typical reply from $8.72 to $4.03, and the cost per reply with compactions included by 48%, over 82 replies.
|
|
14
|
+
|
|
15
|
+
- **The token diet is on by default.** Every conversation compacts at 200k, and before a cold resume over 120k, as listed conversations did in 3.5.17. `TOKEN_DIET_CONVERSATIONS=off` turns it off; a list of conversation keys still limits it to those.
|
|
16
|
+
- **The compaction summary no longer dies on a tool call.** Claude runs it with `--max-turns 1`, and its prompt never said not to use tools: a model that checked a fact first used up the one turn and left no brief (4 of 24 compactions on 7-8 Oct, $35 for nothing). The prompt now says so, and a threshold compaction that does not complete logs why.
|
|
17
|
+
- **A scheduled job's own conversation gets the same cap.** A watch, cron or wakeup resumes the conversation it was made in; once its thread had moved on, nothing compacted that conversation (the Kazee Bugs monitor's grew to 894k, $55 a wake). It is now compacted like its thread, before a cold run and after a run past the threshold, without touching the thread's live session. The usage ledger marks these compactions `scope: "job"`.
|
|
18
|
+
- **Jobs follow their compactions.** The completed-compactions index keeps only the last 20, which the token diet fills in under a day, so jobs lost their way and resumed old, huge conversations (the PBX thread's wakeup and watch: 790k, $86 for two runs). The scheduler now also reads the lineage in the session history, and a recurring job remembers the session it last ran on.
|
|
19
|
+
- **Tests:** `test-token-diet.js` (the default; job conversations end to end through the scheduler), `test-provider-compaction.js` (branch commit, the no-tools summary), `test-provider-scheduler.js` (history lineage, a job keeps its session); `test-usage-accounting.js` pins the classic thresholds with the diet off.
|
|
20
|
+
|
|
3
21
|
## 3.5.17
|
|
4
22
|
|
|
5
23
|
One combined release (Sumeet, 7 Oct 09:21 EAT): Release 1 below, the 7 Oct security fixes, and the dashboard and provider fixes found on 6 Oct. What people will notice: Codex bots start answering in groups when a message concerns them (their checks failed before), a file over 200 MB sent to a bot is refused, and a /dashboard link opens a Continue page first.
|
package/README.md
CHANGED
|
@@ -453,6 +453,9 @@ All stored in `~/.open-claudia/`:
|
|
|
453
453
|
| `USAGE_ALERT_CONTEXT_TOKENS` | No | With alerts on: alert when one completed turn's context tokens exceed this ceiling (default `120000`, `off` disables) |
|
|
454
454
|
| `USAGE_ALERT_RATE_MULTIPLIER` | No | With alerts on: alert when the latest context-token rate exceeds the recent baseline by this multiple (default `1.75`, `off` disables) |
|
|
455
455
|
| `USAGE_ALERT_BASELINE_TURNS` / `USAGE_ALERT_MIN_BASELINE_TURNS` / `USAGE_ALERT_COOLDOWN_MS` | No | Tune token-rate baseline size, minimum sample size, and alert cooldown |
|
|
456
|
+
| `LIMIT_WARN_PERCENTS` | No | Plan-limit warnings in the chat: comma-separated percentages of a 5-hour or weekly window (default `80,95`, `off` disables). Each is said once per window, after the reply that crossed it; never to an outside contact |
|
|
457
|
+
| `LIMIT_PICKUP` | No | `on` (default) or `off`. A message a Claude or Codex usage limit stopped is picked up when the limit resets; the person writing again in that conversation, `/stop`, `/new` or `/end` drops it, and a new limit hit arms it again |
|
|
458
|
+
| `LIMIT_PICKUP_DELAY_S` / `LIMIT_PICKUP_MAX_WAIT_H` | No | Seconds after the reset before the pick-up runs (default `120`); resets further out than this many hours (default `192`) are not waited for |
|
|
456
459
|
| `MEMORY_RECALL_MAX_CHARS` | No | Hard cap for auto-injected pack/entity memory per turn (default `9000`, `off` disables auto recall injection) |
|
|
457
460
|
| `RECALL_ENGINE` | No | Default recall engine when a chat hasn't set one via `/engine` (`classic` or `discoverer`, default `discoverer`) |
|
|
458
461
|
| `RECALL_GRAPH_DB` / `RECALL_METRICS` | No | Override the discoverer graph DB path; `off` on metrics disables per-turn recall logging |
|
package/bot.js
CHANGED
|
@@ -381,6 +381,13 @@ async function safeModeBoot() {
|
|
|
381
381
|
}
|
|
382
382
|
} catch (e) { console.error("interrupted-turn resume failed:", e.message); }
|
|
383
383
|
|
|
384
|
+
// Messages a usage limit stopped are picked up when the limit resets
|
|
385
|
+
// (core/limit-pickup.js): a timer for each one still waiting.
|
|
386
|
+
try {
|
|
387
|
+
const waiting = require("./core/limit-pickup").start(adapters);
|
|
388
|
+
if (waiting.length) console.log(`Limit pick-ups waiting: ${waiting.length}`);
|
|
389
|
+
} catch (e) { console.error("limit pick-up start failed:", e.message); }
|
|
390
|
+
|
|
384
391
|
// Kazee Cards: snoozed owner decisions are asked again when due.
|
|
385
392
|
try { require("./core/cards").startDecisionPump(registry); } catch (e) { console.error("Decision pump start failed:", e.message); }
|
|
386
393
|
|
package/core/config.js
CHANGED
|
@@ -126,15 +126,18 @@ const CLAUDE_PATH = configuredProviderPath("claude");
|
|
|
126
126
|
const CODEX_PATH = config.CODEX_PATH || null;
|
|
127
127
|
const AUTO_COMPACT_TOKENS = parseInt(config.AUTO_COMPACT_TOKENS || process.env.AUTO_COMPACT_TOKENS || "380000", 10);
|
|
128
128
|
const MIN_COMPACT_INTERVAL_MS = parseInt(config.MIN_COMPACT_INTERVAL_MS || process.env.MIN_COMPACT_INTERVAL_MS || "1800000", 10);
|
|
129
|
-
// Token diet
|
|
130
|
-
//
|
|
131
|
-
//
|
|
132
|
-
//
|
|
129
|
+
// Token diet, on for every conversation by default since 3.5.18 (the 3.5.17
|
|
130
|
+
// canary halved the cost of a reply on the owner's bot). TOKEN_DIET_CONVERSATIONS
|
|
131
|
+
// is "*" (all), a comma-separated list of conversation keys as the usage
|
|
132
|
+
// ledger's `project` field writes them (e.g. kazee:<room>#thread:<root>), or
|
|
133
|
+
// "off". A listed conversation compacts at TOKEN_DIET_COMPACT_TOKENS instead of
|
|
133
134
|
// AUTO_COMPACT_TOKENS, and is compacted before a turn that resumes it after
|
|
134
135
|
// TOKEN_DIET_COLD_MINUTES idle (the prompt cache has expired, so the whole
|
|
135
136
|
// context would be re-written) when its context exceeds TOKEN_DIET_COLD_MIN_TOKENS.
|
|
136
|
-
const
|
|
137
|
-
|
|
137
|
+
const TOKEN_DIET_SETTING = String(config.TOKEN_DIET_CONVERSATIONS || process.env.TOKEN_DIET_CONVERSATIONS || "*").trim();
|
|
138
|
+
const TOKEN_DIET_CONVERSATIONS = /^(off|none)$/i.test(TOKEN_DIET_SETTING)
|
|
139
|
+
? []
|
|
140
|
+
: TOKEN_DIET_SETTING.split(",").map((s) => s.trim()).filter(Boolean);
|
|
138
141
|
const TOKEN_DIET_COMPACT_TOKENS = parseInt(config.TOKEN_DIET_COMPACT_TOKENS || process.env.TOKEN_DIET_COMPACT_TOKENS || "200000", 10);
|
|
139
142
|
const TOKEN_DIET_COLD_MINUTES = parseInt(config.TOKEN_DIET_COLD_MINUTES || process.env.TOKEN_DIET_COLD_MINUTES || "60", 10);
|
|
140
143
|
const TOKEN_DIET_COLD_MIN_TOKENS = parseInt(config.TOKEN_DIET_COLD_MIN_TOKENS || process.env.TOKEN_DIET_COLD_MIN_TOKENS || "120000", 10);
|
package/core/handlers.js
CHANGED
|
@@ -87,6 +87,18 @@ function cancelledQueueResult(item, reason) {
|
|
|
87
87
|
};
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
+
// A pick-up waiting for a usage limit to reset (core/limit-pickup.js) in
|
|
91
|
+
// the current conversation, dropped by /stop, /new and /end.
|
|
92
|
+
function dropLimitPickup(reason, state = currentState(), laneKey = currentLaneKey(state)) {
|
|
93
|
+
try { return require("./limit-pickup").cancel(state.userId, laneKey, reason); } catch (_) { return null; }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function pickupDroppedNote(count) {
|
|
97
|
+
return count === 1
|
|
98
|
+
? "I won't pick up your earlier message when the usage limit resets."
|
|
99
|
+
: `I won't pick up your ${count} earlier messages when the usage limits reset.`;
|
|
100
|
+
}
|
|
101
|
+
|
|
90
102
|
// Drops every message queued in one lane: each one's caller gets a cancelled
|
|
91
103
|
// result (no hanging promise), and an idle lane loses its typing heartbeat (the
|
|
92
104
|
// chip would stay on, and the lane could never be pruned). Returns the items.
|
|
@@ -1539,6 +1551,7 @@ register({
|
|
|
1539
1551
|
if (!requireSession()) return;
|
|
1540
1552
|
const result = startNewConversation();
|
|
1541
1553
|
if (!result.ok) return send(`Could not start a new conversation: ${result.error.message}`);
|
|
1554
|
+
dropLimitPickup("/new");
|
|
1542
1555
|
return send(`Session: ${result.project}\nNew ${result.provider} conversation\n\nSend text, voice, or images.`);
|
|
1543
1556
|
},
|
|
1544
1557
|
});
|
|
@@ -1792,10 +1805,16 @@ register({
|
|
|
1792
1805
|
const dropped = lanes.flatMap((lane) => dropLaneQueue(lane, STOP_DROP_REASON));
|
|
1793
1806
|
const stopped = lanes.filter((lane) => stopLane(state, lane)).length;
|
|
1794
1807
|
const sideStopped = await sideCancellation;
|
|
1795
|
-
|
|
1808
|
+
let pickups = [];
|
|
1809
|
+
try { pickups = require("./limit-pickup").cancelAllFor(state.userId, "/stop all"); } catch (_) {}
|
|
1810
|
+
if (!stopped && !dropped.length) {
|
|
1811
|
+
if (sideStopped) return send("Cancelled side response.");
|
|
1812
|
+
return send(pickups.length ? pickupDroppedNote(pickups.length) : "Nothing running.");
|
|
1813
|
+
}
|
|
1796
1814
|
const parts = [];
|
|
1797
1815
|
if (stopped) parts.push(`Cancelled ${stopped} running conversation${stopped === 1 ? "" : "s"}.`);
|
|
1798
1816
|
if (dropped.length) parts.push(droppedNote(dropped, stopped ? "queued" : "waiting"));
|
|
1817
|
+
if (pickups.length) parts.push(pickupDroppedNote(pickups.length));
|
|
1799
1818
|
return send(parts.join(" "), dropOffer(dropped));
|
|
1800
1819
|
}
|
|
1801
1820
|
// /stop cancels the CURRENT conversation's turn only (other threads keep
|
|
@@ -1805,16 +1824,19 @@ register({
|
|
|
1805
1824
|
const lane = laneFor(state);
|
|
1806
1825
|
const sideCancellation = sideChatCoordinator.cancel(state.userId, lane.key);
|
|
1807
1826
|
const dropped = dropLaneQueue(lane, STOP_DROP_REASON);
|
|
1827
|
+
// A pick-up waiting for a usage limit to reset is dropped too.
|
|
1828
|
+
const pickup = dropLimitPickup("/stop", state, lane.key);
|
|
1829
|
+
const pickupNote = pickup ? ` ${pickupDroppedNote(1)}` : "";
|
|
1808
1830
|
if (stopLane(state, lane)) {
|
|
1809
1831
|
await sideCancellation;
|
|
1810
|
-
await send(dropped.length ? `Cancelled. ${droppedNote(dropped, "queued")}` : "Cancelled."
|
|
1832
|
+
await send(`${dropped.length ? `Cancelled. ${droppedNote(dropped, "queued")}` : "Cancelled."}${pickupNote}`, dropOffer(dropped));
|
|
1811
1833
|
} else if (dropped.length) {
|
|
1812
1834
|
// Nothing to cancel: the dropped messages were waiting for a slot (or
|
|
1813
1835
|
// queued behind a turn that is only finishing its tail).
|
|
1814
1836
|
await sideCancellation;
|
|
1815
|
-
await send(droppedNote(dropped, laneBusy(lane) ? "queued" : "waiting")
|
|
1816
|
-
} else if (await sideCancellation) await send(
|
|
1817
|
-
else await send("Nothing running.");
|
|
1837
|
+
await send(`${droppedNote(dropped, laneBusy(lane) ? "queued" : "waiting")}${pickupNote}`, dropOffer(dropped));
|
|
1838
|
+
} else if (await sideCancellation) await send(`Cancelled side response.${pickupNote}`);
|
|
1839
|
+
else await send(pickup ? pickupDroppedNote(1) : "Nothing running.");
|
|
1818
1840
|
},
|
|
1819
1841
|
});
|
|
1820
1842
|
|
|
@@ -1867,16 +1889,42 @@ register({
|
|
|
1867
1889
|
const state = currentState();
|
|
1868
1890
|
const result = endCurrentSession({ state });
|
|
1869
1891
|
if (!result.ok) return send(`Could not end conversation: ${result.error.message}`);
|
|
1892
|
+
dropLimitPickup("/end", state);
|
|
1870
1893
|
send("Ended. Send anything to start a fresh conversation.");
|
|
1871
1894
|
},
|
|
1872
1895
|
});
|
|
1873
1896
|
|
|
1897
|
+
// Plan limits as last reported (core/usage-limits.js), and this person's
|
|
1898
|
+
// pick-up in this conversation if a usage limit stopped a message.
|
|
1899
|
+
function planLimitLines(state = currentState()) {
|
|
1900
|
+
const lines = [];
|
|
1901
|
+
try {
|
|
1902
|
+
const limits = require("./usage-limits");
|
|
1903
|
+
const byProvider = new Map();
|
|
1904
|
+
for (const w of limits.knownWindows()) {
|
|
1905
|
+
if (!byProvider.has(w.provider)) byProvider.set(w.provider, []);
|
|
1906
|
+
byProvider.get(w.provider).push(w);
|
|
1907
|
+
}
|
|
1908
|
+
const order = { five_hour: 0, seven_day: 1 };
|
|
1909
|
+
for (const [provider, windows] of byProvider) {
|
|
1910
|
+
const parts = windows
|
|
1911
|
+
.sort((a, b) => (order[a.id] ?? 9) - (order[b.id] ?? 9))
|
|
1912
|
+
.map((w) => `${limits.windowLabel(w.id).replace(/ limit$/, "")} ${Math.round(w.usedPercent)}%${w.resetsAt ? ` (resets ${limits.formatResetTime(w.resetsAt)})` : ""}`);
|
|
1913
|
+
lines.push(`${limits.providerLabel(provider)} plan limits: ${parts.join(" · ")}`);
|
|
1914
|
+
}
|
|
1915
|
+
const pickup = require("./limit-pickup").get(state.userId, currentLaneKey(state));
|
|
1916
|
+
if (pickup) lines.push(`Picking up your stopped message ${limits.whenPhrase(pickup.runAt)} (/stop drops it).`);
|
|
1917
|
+
} catch (_) {}
|
|
1918
|
+
return lines;
|
|
1919
|
+
}
|
|
1920
|
+
|
|
1874
1921
|
register({
|
|
1875
1922
|
name: "usage", description: "Show token usage & cost for this session",
|
|
1876
1923
|
handler: async (env) => {
|
|
1877
1924
|
if (!authorized(env)) return;
|
|
1878
1925
|
const u = currentState().sessionUsage;
|
|
1879
|
-
|
|
1926
|
+
const limitLines = planLimitLines();
|
|
1927
|
+
if (u.turns === 0) return send(["No usage yet in this session.", ...limitLines].join("\n"));
|
|
1880
1928
|
const fmt = (n) => n >= 1000 ? (n / 1000).toFixed(1) + "k" : String(n);
|
|
1881
1929
|
const lines = [
|
|
1882
1930
|
`*Session usage* (${u.turns} turn${u.turns === 1 ? "" : "s"})`,
|
|
@@ -1892,7 +1940,8 @@ register({
|
|
|
1892
1940
|
if (u.subagentThreads > 0) {
|
|
1893
1941
|
lines.push(`Provider sub-agents: ${u.subagentThreads} thread${u.subagentThreads === 1 ? "" : "s"} · ${fmt(u.subagentInputTokens || 0)} in / ${fmt(u.subagentOutputTokens || 0)} out (spawned by the model; not in the totals above)`);
|
|
1894
1942
|
}
|
|
1895
|
-
if (
|
|
1943
|
+
if (limitLines.length) lines.push(...limitLines);
|
|
1944
|
+
else if (Number.isFinite(u.quotaUsedPercent)) {
|
|
1896
1945
|
lines.push(`Plan quota used: ${u.quotaUsedPercent}%${u.quotaResetsAt ? ` (resets ${String(u.quotaResetsAt).slice(0, 16).replace("T", " ")}Z)` : ""}`);
|
|
1897
1946
|
}
|
|
1898
1947
|
if (u.lastInputTokens > 200000) {
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
// Limit pick-up: a chat turn stopped by a plan usage limit runs again when
|
|
2
|
+
// the limit resets (Sumeet, 7 Oct 2026: "have it pick up when the session
|
|
3
|
+
// replenishes … if the user sends it a message it cancels and if the session
|
|
4
|
+
// limit message comes up again it will just rearm").
|
|
5
|
+
//
|
|
6
|
+
// - arm(): a foreground chat turn failed on PROVIDER_USAGE_LIMIT with a
|
|
7
|
+
// known reset time. <config>/limit-pickups.json keeps ONE pick-up per
|
|
8
|
+
// person per conversation (a newer one replaces it; in a group each
|
|
9
|
+
// sender has their own lane and session) with what is needed to run the
|
|
10
|
+
// message again as the person who sent it (the turn journal's shape).
|
|
11
|
+
// - cancel(): that person writing again in that conversation drops it
|
|
12
|
+
// (router.onMessage, and runner.runAgent for turns started another way),
|
|
13
|
+
// as do /stop, /new and /end: the conversation moved on.
|
|
14
|
+
// - start(adapters) at boot sets a timer per pick-up; a reset that passed
|
|
15
|
+
// while the bot was down fires shortly after boot. When it fires the
|
|
16
|
+
// record is removed and the message runs with a "the limit has reset,
|
|
17
|
+
// pick it up" prompt. If that run hits a limit again it re-arms, keeping
|
|
18
|
+
// the original message and counting attempts (MAX_ATTEMPTS at most).
|
|
19
|
+
// LIMIT_PICKUP=off turns it off (the notice then only says when it resets).
|
|
20
|
+
|
|
21
|
+
const path = require("path");
|
|
22
|
+
const crypto = require("crypto");
|
|
23
|
+
const CONFIG_DIR = require("../config-dir");
|
|
24
|
+
const { atomicWriteFileSync, readJsonWithFallback, withFileLock } = require("./fsutil");
|
|
25
|
+
const limits = require("./usage-limits");
|
|
26
|
+
|
|
27
|
+
const FILE = () => process.env.OC_LIMIT_PICKUPS_FILE || path.join(CONFIG_DIR, "limit-pickups.json");
|
|
28
|
+
const MAX_ATTEMPTS = 5;
|
|
29
|
+
// setTimeout's ceiling is ~24.8 days; a plan window is a week at most.
|
|
30
|
+
const MAX_TIMER_MS = 2 ** 31 - 1;
|
|
31
|
+
const BOOT_GRACE_MS = 60000;
|
|
32
|
+
|
|
33
|
+
function setting(name) {
|
|
34
|
+
if (process.env[name] !== undefined) return process.env[name];
|
|
35
|
+
try { return require("./config").config[name]; } catch (_) { return undefined; }
|
|
36
|
+
}
|
|
37
|
+
function enabled() {
|
|
38
|
+
return !/^(off|false|0|no)$/i.test(String(setting("LIMIT_PICKUP") ?? "on").trim());
|
|
39
|
+
}
|
|
40
|
+
// Wait this long after the reset, so the provider's window has turned over.
|
|
41
|
+
function delayMs() {
|
|
42
|
+
const n = Number(setting("LIMIT_PICKUP_DELAY_S"));
|
|
43
|
+
return (Number.isFinite(n) && n >= 0 ? n : 120) * 1000;
|
|
44
|
+
}
|
|
45
|
+
// A reset further out than this (a monthly spend cap) is not waited for.
|
|
46
|
+
function maxWaitMs() {
|
|
47
|
+
const n = Number(setting("LIMIT_PICKUP_MAX_WAIT_H"));
|
|
48
|
+
return (Number.isFinite(n) && n > 0 ? n : 192) * 3600000;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// A test run that did not point the store somewhere else never touches the
|
|
52
|
+
// live bot's pick-ups.
|
|
53
|
+
function storeOn() {
|
|
54
|
+
return !(process.env.OPEN_CLAUDIA_TEST === "1" && !process.env.OPEN_CLAUDIA_CONFIG_DIR && !process.env.OC_LIMIT_PICKUPS_FILE);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function readAll() {
|
|
58
|
+
const data = readJsonWithFallback(FILE(), null);
|
|
59
|
+
return data && typeof data === "object" && data.pickups && typeof data.pickups === "object" ? data : { v: 1, pickups: {} };
|
|
60
|
+
}
|
|
61
|
+
function update(fn) {
|
|
62
|
+
return withFileLock(FILE(), () => {
|
|
63
|
+
const data = readAll();
|
|
64
|
+
const out = fn(data.pickups);
|
|
65
|
+
atomicWriteFileSync(FILE(), JSON.stringify(data, null, 2) + "\n");
|
|
66
|
+
return out;
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// One pick-up per person per conversation.
|
|
71
|
+
function keyFor(canonicalUserId, laneKey) {
|
|
72
|
+
return `${canonicalUserId || "-"}::${laneKey}`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Keys with a pick-up on disk, so the check on every inbound message reads
|
|
76
|
+
// no file. Only this process writes the store.
|
|
77
|
+
let indexCache = null;
|
|
78
|
+
function index() {
|
|
79
|
+
if (!indexCache) indexCache = new Set(storeOn() ? Object.keys(readAll().pickups) : []);
|
|
80
|
+
return indexCache;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function list() {
|
|
84
|
+
return storeOn() ? Object.values(readAll().pickups) : [];
|
|
85
|
+
}
|
|
86
|
+
function get(canonicalUserId, laneKey) {
|
|
87
|
+
if (!storeOn() || !index().has(keyFor(canonicalUserId, laneKey))) return null;
|
|
88
|
+
return readAll().pickups[keyFor(canonicalUserId, laneKey)] || null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ── wording ─────────────────────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
function at(ms, now = Date.now()) {
|
|
94
|
+
return limits.whenPhrase(ms, { now });
|
|
95
|
+
}
|
|
96
|
+
function limitName(provider, limitType) {
|
|
97
|
+
return `${limits.providerLabel(provider)}'s ${limits.windowLabel(limitType)}`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const PICKUP_PROMPT = (hitAt, provider, original) => `Your ${limits.providerLabel(provider)} usage limit stopped this turn at ${hitAt}. The limit has reset, so pick it up now. The message was: ${original}\n\nCheck what you already did (your history, git, files, tool state, the chat) before acting; never repeat a send, push, deploy or write that already happened; then finish.`;
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* What a turn stopped by a limit should do: arm a pick-up (armable) or only
|
|
104
|
+
* say when the limit lifts. Pure: deliverForegroundResult posts `notice` and
|
|
105
|
+
* then calls arm() with the plan when armable.
|
|
106
|
+
*/
|
|
107
|
+
function planFor(result, runContext, { now = Date.now(), windows = [], guarded = false } = {}) {
|
|
108
|
+
const hit = limits.limitHitDetails({
|
|
109
|
+
provider: runContext?.provider, error: result?.error, rateLimit: result?.rateLimit, windows, now,
|
|
110
|
+
});
|
|
111
|
+
if (!hit) return null;
|
|
112
|
+
// An outside contact is not told which model or plan the bot runs on.
|
|
113
|
+
const name = guarded ? "My usage limit" : limitName(hit.provider, hit.limitType);
|
|
114
|
+
const known = Number.isFinite(hit.resetsAt) && hit.resetsAt > now - 6 * 3600000;
|
|
115
|
+
const runAt = known ? Math.max(hit.resetsAt, now) + delayMs() : null;
|
|
116
|
+
const pickup = runContext?.limitPickup || null;
|
|
117
|
+
const attempts = (pickup?.attempts || 0) + 1;
|
|
118
|
+
const armable = enabled()
|
|
119
|
+
&& known
|
|
120
|
+
&& runAt - now <= maxWaitMs()
|
|
121
|
+
&& attempts <= MAX_ATTEMPTS
|
|
122
|
+
&& runContext?.purpose === "foreground"
|
|
123
|
+
&& !runContext?.passive
|
|
124
|
+
&& !runContext?.liveCall
|
|
125
|
+
&& !!runContext?.origin?.adapterId
|
|
126
|
+
&& !!runContext?.channelId;
|
|
127
|
+
let notice;
|
|
128
|
+
if (known && runContext?.purpose && runContext.purpose !== "foreground") {
|
|
129
|
+
notice = `⏳ ${name} is used up, so this ${runContext.purpose === "scheduled" ? "scheduled run" : "run"} stopped. It resets ${at(hit.resetsAt, now)}.`;
|
|
130
|
+
} else if (armable) {
|
|
131
|
+
const again = pickup && pickup.limitType && pickup.limitType === hit.limitType ? "is still used up" : "is used up too";
|
|
132
|
+
notice = pickup
|
|
133
|
+
? `⏳ ${name} ${again}. I'll try your message again ${at(runAt, now)}. If you write to me before then, I'll answer that instead and drop this.`
|
|
134
|
+
: `⏳ ${name} is used up, so I stopped here. I'll pick your message back up ${at(runAt, now)}, when it resets. If you write to me before then, I'll answer that instead and drop this.`;
|
|
135
|
+
} else if (known) {
|
|
136
|
+
notice = attempts > MAX_ATTEMPTS
|
|
137
|
+
? `⏳ ${name} is used up again, after ${MAX_ATTEMPTS} tries. It resets ${at(hit.resetsAt, now)}: send your message again then.`
|
|
138
|
+
: `⏳ ${name} is used up. It resets ${at(hit.resetsAt, now)}: send your message again then.`;
|
|
139
|
+
} else if (guarded) {
|
|
140
|
+
notice = "⏳ My usage limit is reached and I can't tell when it resets, so send your message again later.";
|
|
141
|
+
} else {
|
|
142
|
+
notice = `⏳ ${limits.providerLabel(hit.provider)} says its usage limit is reached ("${String(hit.text || "").slice(0, 160)}"). I can't tell when it resets, so send your message again later.`;
|
|
143
|
+
}
|
|
144
|
+
return { hit, armable, runAt, attempts, notice };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ── arm / cancel ────────────────────────────────────────────────────────
|
|
148
|
+
|
|
149
|
+
const timers = new Map(); // keyFor(person, conversation) -> timeout
|
|
150
|
+
let runtime = null; // { adapters, deps } once start() ran
|
|
151
|
+
|
|
152
|
+
function conversationOf(runContext) {
|
|
153
|
+
return require("./turn-journal").conversationOf(runContext);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function arm(runContext, plan, { now = Date.now() } = {}) {
|
|
157
|
+
if (!storeOn() || !plan?.armable) return null;
|
|
158
|
+
const pickup = runContext.limitPickup || null;
|
|
159
|
+
const record = {
|
|
160
|
+
v: 1,
|
|
161
|
+
id: crypto.randomBytes(6).toString("hex"),
|
|
162
|
+
conversation: conversationOf(runContext),
|
|
163
|
+
turn: {
|
|
164
|
+
// A pick-up that hits a limit again keeps the person's original message.
|
|
165
|
+
prompt: pickup?.originalPrompt || String(runContext.userPrompt || ""),
|
|
166
|
+
imagePaths: runContext.imagePaths || [],
|
|
167
|
+
attachments: runContext.attachments || [],
|
|
168
|
+
replyToMsgId: runContext.replyToMsgId || null,
|
|
169
|
+
project: runContext.project ? { name: runContext.project.name || null, dir: runContext.project.dir || null } : null,
|
|
170
|
+
},
|
|
171
|
+
provider: plan.hit.provider,
|
|
172
|
+
limitType: plan.hit.limitType || null,
|
|
173
|
+
resetsAt: plan.hit.resetsAt,
|
|
174
|
+
runAt: plan.runAt,
|
|
175
|
+
armedAt: now,
|
|
176
|
+
firstHitAt: pickup?.firstHitAt || now,
|
|
177
|
+
attempts: plan.attempts,
|
|
178
|
+
};
|
|
179
|
+
const key = keyFor(record.conversation.canonicalUserId, record.conversation.laneKey);
|
|
180
|
+
update((pickups) => { pickups[key] = record; });
|
|
181
|
+
index().add(key);
|
|
182
|
+
schedule(key, record, now);
|
|
183
|
+
console.log(`[limit-pickup] armed ${key}: ${record.provider} ${record.limitType || "limit"} resets ${new Date(record.resetsAt).toISOString()}, runs ${new Date(record.runAt).toISOString()} (attempt ${record.attempts})`);
|
|
184
|
+
return record;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function drop(keys, reason) {
|
|
188
|
+
const wanted = keys.filter((key) => index().has(key) || timers.has(key));
|
|
189
|
+
if (!wanted.length) return [];
|
|
190
|
+
for (const key of wanted) {
|
|
191
|
+
const timer = timers.get(key);
|
|
192
|
+
if (timer) { clearTimeout(timer); timers.delete(key); }
|
|
193
|
+
index().delete(key);
|
|
194
|
+
}
|
|
195
|
+
const removed = update((pickups) => wanted.filter((key) => pickups[key]).map((key) => {
|
|
196
|
+
const record = pickups[key];
|
|
197
|
+
delete pickups[key];
|
|
198
|
+
return record;
|
|
199
|
+
}));
|
|
200
|
+
for (const record of removed) console.log(`[limit-pickup] cancelled ${keyFor(record.conversation.canonicalUserId, record.conversation.laneKey)} (${reason})`);
|
|
201
|
+
return removed;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Drop this person's pick-up in this conversation (they wrote, /stop, /new, /end). */
|
|
205
|
+
function cancel(canonicalUserId, laneKey, reason = "a new message") {
|
|
206
|
+
if (!laneKey || !storeOn()) return null;
|
|
207
|
+
return drop([keyFor(canonicalUserId, laneKey)], reason)[0] || null;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Drop every pick-up of this person (/stop all). */
|
|
211
|
+
function cancelAllFor(canonicalUserId, reason = "/stop all") {
|
|
212
|
+
if (!storeOn()) return [];
|
|
213
|
+
const prefix = `${canonicalUserId || "-"}::`;
|
|
214
|
+
return drop([...index()].filter((key) => key.startsWith(prefix)), reason);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ── timers and the pick-up run ──────────────────────────────────────────
|
|
218
|
+
|
|
219
|
+
function schedule(key, record, now = Date.now()) {
|
|
220
|
+
if (!runtime) return; // start() at boot sets the timers
|
|
221
|
+
const old = timers.get(key);
|
|
222
|
+
if (old) clearTimeout(old);
|
|
223
|
+
const wait = Math.min(Math.max(record.runAt - now, 0), MAX_TIMER_MS);
|
|
224
|
+
const timer = setTimeout(() => {
|
|
225
|
+
timers.delete(key);
|
|
226
|
+
// The ceiling cut the wait short: set the rest.
|
|
227
|
+
if (record.runAt - Date.now() > 1000) return schedule(key, record);
|
|
228
|
+
fire(key, record.id).catch((error) => console.warn(`[limit-pickup] ${key}: ${error?.message || error}`));
|
|
229
|
+
}, wait);
|
|
230
|
+
timer.unref?.();
|
|
231
|
+
timers.set(key, timer);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function defaultDeps() {
|
|
235
|
+
const journal = require("./turn-journal");
|
|
236
|
+
return {
|
|
237
|
+
...journal.defaultDeps(),
|
|
238
|
+
chatScope: journal.chatScope,
|
|
239
|
+
findAdapter: journal.findAdapter,
|
|
240
|
+
isGuarded: () => { try { return require("./relationship").isCurrentSpeakerGuarded(); } catch (_) { return false; } },
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async function fire(key, id) {
|
|
245
|
+
if (!runtime) return null;
|
|
246
|
+
const { adapters, deps } = runtime;
|
|
247
|
+
// Take it out first: it runs once; a new limit hit arms a new one.
|
|
248
|
+
const record = update((pickups) => {
|
|
249
|
+
const current = pickups[key];
|
|
250
|
+
if (!current || (id && current.id !== id)) return null;
|
|
251
|
+
delete pickups[key];
|
|
252
|
+
return current;
|
|
253
|
+
});
|
|
254
|
+
if (!record) return null;
|
|
255
|
+
index().delete(key);
|
|
256
|
+
if (!enabled()) return null;
|
|
257
|
+
if (deps.isSafeMode()) {
|
|
258
|
+
// Safe mode runs no turns: keep it for the next boot.
|
|
259
|
+
update((pickups) => { pickups[key] = pickups[key] || record; });
|
|
260
|
+
index().add(key);
|
|
261
|
+
return null;
|
|
262
|
+
}
|
|
263
|
+
const adapter = deps.findAdapter(adapters || [], record.conversation);
|
|
264
|
+
if (!adapter) {
|
|
265
|
+
console.warn(`[limit-pickup] ${key}: its channel is not loaded; dropped`);
|
|
266
|
+
return null;
|
|
267
|
+
}
|
|
268
|
+
const scope = deps.chatScope(record.conversation, adapter, deps);
|
|
269
|
+
const hitAt = limits.formatResetTime(record.firstHitAt, { now: deps.now() });
|
|
270
|
+
return deps.runInChat(scope, async () => {
|
|
271
|
+
// An outside contact is not told which model or plan the bot runs on.
|
|
272
|
+
const which = deps.isGuarded() ? "my usage limit" : limitName(record.provider, record.limitType);
|
|
273
|
+
await Promise.resolve(deps.send(`Picking up your message from ${hitAt} now that ${which} has reset.`, { replyTo: record.turn.replyToMsgId || undefined })).catch(() => {});
|
|
274
|
+
return deps.runAgent(PICKUP_PROMPT(hitAt, record.provider, record.turn.prompt), record.turn.project?.dir || null, record.turn.replyToMsgId, {
|
|
275
|
+
purpose: "foreground",
|
|
276
|
+
imagePaths: record.turn.imagePaths || [],
|
|
277
|
+
attachments: record.turn.attachments || [],
|
|
278
|
+
limitPickup: { originalPrompt: record.turn.prompt, firstHitAt: record.firstHitAt, attempts: record.attempts, limitType: record.limitType },
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Boot (after the adapters connect): a timer per waiting pick-up. */
|
|
284
|
+
function start(adapters, overrides = {}) {
|
|
285
|
+
const deps = { ...defaultDeps(), ...overrides };
|
|
286
|
+
runtime = { adapters, deps };
|
|
287
|
+
const now = deps.now();
|
|
288
|
+
const out = [];
|
|
289
|
+
indexCache = null;
|
|
290
|
+
for (const record of list()) {
|
|
291
|
+
if (!record.conversation?.laneKey) continue;
|
|
292
|
+
const key = keyFor(record.conversation.canonicalUserId, record.conversation.laneKey);
|
|
293
|
+
// A reset that passed while the bot was down: run shortly after boot,
|
|
294
|
+
// once the resumed and restored turns have started.
|
|
295
|
+
schedule(key, record.runAt < now + BOOT_GRACE_MS ? { ...record, runAt: now + BOOT_GRACE_MS } : record, now);
|
|
296
|
+
out.push(key);
|
|
297
|
+
}
|
|
298
|
+
return out;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function _resetForTests() {
|
|
302
|
+
for (const timer of timers.values()) clearTimeout(timer);
|
|
303
|
+
timers.clear();
|
|
304
|
+
runtime = null;
|
|
305
|
+
indexCache = null;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
module.exports = {
|
|
309
|
+
PICKUP_PROMPT, MAX_ATTEMPTS,
|
|
310
|
+
enabled, delayMs, maxWaitMs,
|
|
311
|
+
keyFor, planFor, arm, cancel, cancelAllFor, list, get, start, fire,
|
|
312
|
+
_resetForTests,
|
|
313
|
+
};
|
|
@@ -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);
|