@inetafrica/open-claudia 3.5.20 → 3.5.23
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 +8 -3
- package/CHANGELOG.md +27 -0
- package/README.md +5 -4
- package/core/actions.js +4 -0
- package/core/auth-flow.js +7 -2
- package/core/dream.js +132 -10
- package/core/handlers.js +16 -2
- package/core/limit-pickup.js +24 -20
- package/core/providers/claude.js +15 -8
- package/core/providers/events.js +25 -1
- package/core/providers/utility-policy.js +31 -6
- package/core/runner.js +203 -10
- package/core/scheduler.js +57 -0
- package/core/state.js +14 -0
- package/core/system-prompt.js +1 -1
- package/core/turn-journal.js +55 -2
- package/core/usage-limits.js +110 -0
- package/core/utility-agent.js +109 -15
- package/package.json +2 -2
- package/test-fixtures/fake-agent-cli.js +46 -2
- package/test-provider-boot-matrix.js +6 -2
- package/test-provider-capabilities.js +1 -1
- package/test-provider-claude.js +7 -5
- package/test-provider-events.js +43 -1
- package/test-provider-language.js +11 -1
package/.env.example
CHANGED
|
@@ -74,12 +74,17 @@ USAGE_ALERT_COOLDOWN_MS=1800000
|
|
|
74
74
|
# when a window's use passes these percentages, once per window (off = never).
|
|
75
75
|
LIMIT_WARN_PERCENTS=80,95
|
|
76
76
|
# A message a usage limit stopped is picked up when the limit resets, unless
|
|
77
|
-
# the person writes again first: on (default) | off.
|
|
77
|
+
# the person writes again first: on (default) | off. Scheduled runs and the
|
|
78
|
+
# dream's AI step always wait for the reset.
|
|
78
79
|
LIMIT_PICKUP=on
|
|
79
|
-
# Seconds after the reset before
|
|
80
|
-
# than LIMIT_PICKUP_MAX_WAIT_H hours (default 192) is not waited
|
|
80
|
+
# Seconds after the reset before waiting work runs (default 120); a reset
|
|
81
|
+
# further out than LIMIT_PICKUP_MAX_WAIT_H hours (default 192) is not waited
|
|
82
|
+
# for. One rule for chat messages, scheduled runs and the dream.
|
|
81
83
|
LIMIT_PICKUP_DELAY_S=120
|
|
82
84
|
LIMIT_PICKUP_MAX_WAIT_H=192
|
|
85
|
+
# Messages sent while a conversation is busy run together as one turn when it
|
|
86
|
+
# ends (on, default); off runs them one at a time.
|
|
87
|
+
QUEUE_BATCH=on
|
|
83
88
|
MEMORY_RECALL_MAX_CHARS=9000
|
|
84
89
|
# Default recall engine when a chat hasn't picked one with /engine: classic | discoverer
|
|
85
90
|
RECALL_ENGINE=classic
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,32 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 3.5.23
|
|
4
|
+
|
|
5
|
+
Usage limits as one rule, /stop as a real cancel, the queue's ✕ and run-together, and background checks on Haiku 5.5 and Sonnet 5.5 (Sumeet, 9 Oct 15:56 EAT: "yeh release and upgrade all the bots, include rahil for the updates going forward"). 3.5.20 below, the Browser app, only ever went out as a canary build, so this is the first release that brings it to everyone. 3.5.21 and 3.5.22 were tagged with the same changes but never published: 3.5.21's build stopped on the new /stop and queue test, which needed the fake agent its neighbours run on (CI has no Claude or Codex installed), and 3.5.22's builds stopped on two timing checks that busy build machines pushed past their limits (the boot matrix's 30 s per boot, the /stop test's 3 s and 2.5 s windows); those checks now allow more time.
|
|
6
|
+
|
|
7
|
+
Usage limits as one runtime rule (Sumeet, 9 Oct 2026 after the 07:00 dream failed on his bots: "it should use whatever the active model is … if the Claude model's out of usage … we can't assume that the other will be available … do the standard wake up, the delay until the bot's session has been replenished … the handling of session limits working as a foundational concept rather than just being hacks"). On 9 Oct the 07:00 dream's AI step failed on 18 of 24 fleet bots: Codex bots dreamed on a signed-out Claude, used-up limits failed instead of waiting, and Claude Code's refresh-lock race was final. No chats failed.
|
|
8
|
+
|
|
9
|
+
- **Work a usage limit stops waits for the reset, the same way everywhere.** One rule (`core/usage-limits.js` `limitRetryAt`: 2 minutes after the reset, `LIMIT_PICKUP_DELAY_S`; never a reset more than `LIMIT_PICKUP_MAX_WAIT_H`, 192 h, away) now serves a chat message (the 3.5.18 pick-up), a scheduled run and the dream. Never on the other provider: it may not be signed in, or be used up too.
|
|
10
|
+
- **A scheduled run a limit stops waits for the reset.** A wake-up, watch or cron that hit a limit used to retry every 30 s and give up after its attempts ("gave up and will not fire"). It now waits in the jobs store until the reset ("⏳ Claude's weekly limit is used up, so this scheduled run waits. I'll run it at 11:02, when it resets."), without using up an attempt; a cron's ticks meanwhile start nothing; the run after the reset is told it waited, and a reset that is used up again waits again, at most 5 times.
|
|
11
|
+
- **The dream's AI step waits for the reset.** The automatic filing and graph upkeep run on time; the AI-led merges and self-review run after the reset ("⏳ Codex's weekly limit is used up, so the AI-led merges wait for its reset: I'll run them tomorrow at 11:02."), on a timer kept in the dream state, so a restart keeps it. A sign-in that died says so plainly ("Claude's sign-in on this bot has expired (send /login to sign in again)").
|
|
12
|
+
- **Background work with nobody behind it runs on the bot's own provider.** The dream, introspection and other checks outside any chat now use the provider the owner chats on (`/backend`, `/login`), else the one a new chat starts on. They used to take the first provider that did not look signed out, which on a Codex bot was a signed-out Claude.
|
|
13
|
+
- **A signed-out Claude reads as signed out.** `claude auth status` prints `{"loggedIn": false}` (Claude Code 2.1), which no check matched, so the bot read it as "unknown" and still chose Claude.
|
|
14
|
+
- **One reading of a provider's failure for chats and background calls** (`providerFailureCode`). Background calls now see a plan limit as `PROVIDER_USAGE_LIMIT` with its reset time (it was a plain `UTILITY_PROVIDER_ERROR`). A dead sign-in ("Failed to authenticate: OAuth session expired and could not be refreshed", "OAuth access token is invalid", Codex's "Your authentication token has expired") is a sign-in error in chats and background calls alike, so a chat gets the /login help instead of a raw error. Claude Code's refresh-lock race ("another Claude Code process is refreshing it") gets one more try, 20 s later, in work nobody waits on (the dream, memory review, sub-agents); a chat's own checks go without rather than hold the reply up.
|
|
15
|
+
- **A limit is recorded for every waiter.** Any run a limit stops (a chat turn, a scheduled run, a background call) records the provider as used up until its reset (`usage-limits.json` `blocks`); background calls do not start it until then, and any run on it that works clears the record. Chat turns never read the record: they always try, so the provider stays the judge. A weekly Opus limit stops only Opus calls.
|
|
16
|
+
- **Tests:** `test-limit-waits.js` (new: background calls through the fake Claude CLI, scheduled runs, the dream and its timer, provider choice with nobody behind a call, the signed-out status), `test-usage-limits.js` (the rule and the record), `test-provider-events.js` (Claude Code 2.1.280's own sign-in and refresh-race wording); the fake agent CLI gains a `provider-error` scenario (`FAKE_AGENT_ERROR_TEXT`, `FAKE_AGENT_ERROR_ONCE_FILE`).
|
|
17
|
+
|
|
18
|
+
/stop and the queue (Sumeet, 9 Oct 14:18 EAT, with a screenshot of home pi: "it doesn't seem to understand i cancelled something … one for a queued message a small card with delete queued … i can delete message 2 and 4 arbitrarily from the queue … when the turn finishes … get all the messages that are queued at once? no point of them coming in series is there?").
|
|
19
|
+
|
|
20
|
+
- **A /stop is a cancel, not a cut.** /stop kills the provider mid-turn. On the next message Claude Code resumes the session and writes the stopped turn into the history the way a restart's cut reads ("[Request interrupted by user for tool use]", then "Continue from where you left off."), and the system prompt (since 3.5.11) says to finish work a restart cut off. So the stopped request came back: home pi answered the message after "/stop" on "ok play big bang" with "Big Bang didn't start … Just say if you still want it." /stop now marks the conversation in its turn-journal file (kept across a restart), and the conversation's next turn opens with a note that the person stopped that request and it is cancelled; the system prompt says so too. A stop before the provider started, or of a compaction, leaves no note; `/new`, `/end` and a project change drop it.
|
|
21
|
+
- **Each "Queued." notice has a ✕ Remove from queue button.** It takes that one message out of the queue, so of four queued messages the second and fourth can go while the others stay ("Removed from the queue: "…""). Only the person who sent it, or the owner, can remove it; a message that already started says so.
|
|
22
|
+
- **Queued messages run together.** When a turn ends, the messages the person sent meanwhile run as one turn ("3 messages arrived while you were busy … where a later one changes or cancels an earlier one, the later one wins"), in order, with every image and file, the reply answering the newest. They used to run one turn each, in series, each without the others. Only the same person's plain chat messages in the same session, project, provider and model join (a scheduled run, a side chat's follow-up, a limit pick-up and a live call run on their own), at most 10 per turn. `QUEUE_BATCH=off` runs them one at a time again.
|
|
23
|
+
- **Tests:** `test-stop-queue.js` (new: the note after a /stop, once, kept on disk, not after `/new` or a stop before the provider started; three queued messages as one turn with no journal leftovers; ✕ on two of four; who may press; what joins a batch), `test-turn-journal.js` (the stop survives a restart's boot pass); the fake agent CLI logs every `[[tag:…]]` in a prompt and `FAKE_AGENT_PROMPT_PROBES` matches.
|
|
24
|
+
|
|
25
|
+
Background checks on Claude Haiku 5.5 and Sonnet 5.5 (Sumeet, 9 Oct 14:51 EAT: "check if anything using haiku and have it shifted to the new haiku 5.5", then 15:36: "sonnet should also be just 5.5").
|
|
26
|
+
|
|
27
|
+
- **The low tier is Claude Haiku 5.5** (`claude-haiku-5-5`, was `claude-haiku-4-5-20251001`): the recall router and its warm walker, the recall filter, passive group replies, thread titles, the memory duplicate check and the agency deliberation. List price $0.10 / $0.50 per million tokens for prompts up to 100,000 tokens ($0.50 / $2.50 above), against $1 / $5 for Haiku 4.5. Checked live through the utility agent and Claude Code 2.1.280: a structured duplicate-check reply and a plain thread title, about 5 s each. The /model picker offers Haiku 5.5 instead of 4.5; a typed `/model claude-haiku-4-5-20251001` still works.
|
|
28
|
+
- **The medium tier is Claude Sonnet 5.5** (`claude-sonnet-5-5`, was `claude-sonnet-4-6`): the post-turn memory reviewer, the tool-rule guard and `open-claudia agent` sub-agents. List price $2 / $10 per million tokens, cache reads $0.10, against $3 / $15 and $0.30 for Sonnet 4.6; Sonnet 5.5 uses the newer tokenizer (about 30% more tokens for the same text), so a call costs roughly 15% less on fresh input and output and over half less on cache reads. The /model picker offers Sonnet 5.5 instead of 4.6; a typed `/model claude-sonnet-4-6` still works. A weekly Sonnet limit still stops only Sonnet work.
|
|
29
|
+
|
|
3
30
|
## 3.5.20
|
|
4
31
|
|
|
5
32
|
The Browser app in Kazee Chat, bot apps v1 (Sumeet, 9 Oct 12:02 EAT: "fine go ahead and push but also ensure you clear the images after they all done"). Kazee Chat opens it through Agent Space, which calls these pod routes with the control token.
|
package/README.md
CHANGED
|
@@ -346,7 +346,7 @@ Unsupported optional controls return a provider-specific explanation and do not
|
|
|
346
346
|
|
|
347
347
|
## Utility provider policy
|
|
348
348
|
|
|
349
|
-
Background intelligence—sub-agents, recall, memory review, dream/introspection, and the external-person enforcer—uses one provider-selection policy. A per-purpose override is checked first, then `UTILITY_PROVIDER` (default `active`)
|
|
349
|
+
Background intelligence—sub-agents, recall, memory review, dream/introspection, and the external-person enforcer—uses one provider-selection policy. A per-purpose override is checked first, then `UTILITY_PROVIDER` (default `active`): the speaker's provider when a chat exists, and for work with nobody behind it (the dream, introspection) the provider the owner chats on, else `DEFAULT_PROVIDER`, else the one a new chat starts on. A provider a usage limit has used up is not started for background work until its reset; such work waits for the reset (the dream) or goes without (a recall check), and never moves to the other provider. Providers translate the neutral `low`, `medium`, and `high` tiers to their own models; exact per-provider model overrides remain available.
|
|
350
350
|
|
|
351
351
|
Foreground turns never silently fall back to a different provider. Utility work uses `PROVIDER_FALLBACKS` only when the operator explicitly supplies an ordered list, starts a fresh provider session, and records the provider/model used. The relationship enforcer may try that configured list, but total failure always fails closed and asks the owner to decide.
|
|
352
352
|
|
|
@@ -455,7 +455,8 @@ All stored in `~/.open-claudia/`:
|
|
|
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
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
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
|
|
458
|
+
| `LIMIT_PICKUP_DELAY_S` / `LIMIT_PICKUP_MAX_WAIT_H` | No | Seconds after the reset before waiting work runs (default `120`); resets further out than this many hours (default `192`) are not waited for. One rule for a picked-up message, a scheduled run a limit stopped (it waits instead of failing) and the dream's AI step |
|
|
459
|
+
| `QUEUE_BATCH` | No | `on` (default) or `off`. Messages a person sends while their conversation is busy run together as one turn when it ends (the same person's plain chat messages, at most 10); `off` runs them one at a time. Each "Queued." notice has a ✕ Remove from queue button either way |
|
|
459
460
|
| `MEMORY_RECALL_MAX_CHARS` | No | Hard cap for auto-injected pack/entity memory per turn (default `9000`, `off` disables auto recall injection) |
|
|
460
461
|
| `RECALL_ENGINE` | No | Default recall engine when a chat hasn't set one via `/engine` (`classic` or `discoverer`, default `discoverer`) |
|
|
461
462
|
| `RECALL_GRAPH_DB` / `RECALL_METRICS` | No | Override the discoverer graph DB path; `off` on metrics disables per-turn recall logging |
|
|
@@ -468,11 +469,11 @@ All stored in `~/.open-claudia/`:
|
|
|
468
469
|
| `TRANSCRIPT_MAX_ENTRY_CHARS` | No | Max chars per transcript entry (default `12000`) |
|
|
469
470
|
| `TRANSCRIPTS_DIR` / `PACKS_DIR` / `ENTITIES_DIR` | No | Override storage directories |
|
|
470
471
|
| `PACK_REVIEW` | No | `off` disables the post-turn pack/entity reviewer (default on) |
|
|
471
|
-
| `PACK_REVIEW_MODEL` | No | Model for the reviewer (default `
|
|
472
|
+
| `PACK_REVIEW_MODEL` | No | Model for the reviewer (default: the provider's medium tier, `claude-sonnet-5-5` on Claude). Background checks without a model of their own use the low tier: `claude-haiku-5-5` on Claude |
|
|
472
473
|
| `PACK_MATCH_THRESHOLD` / `ENTITY_MATCH_THRESHOLD` | No | Router match score thresholds (default `2`) |
|
|
473
474
|
| `DREAM` | No | `off` disables the nightly memory consolidation pass (default on) |
|
|
474
475
|
| `DREAM_CRON` | No | Schedule for the dream pass (default `0 4 * * *`) |
|
|
475
|
-
| `DREAM_PROVIDER` | No | Provider for dream and introspection (`claude` or `codex`); otherwise
|
|
476
|
+
| `DREAM_PROVIDER` | No | Provider for dream and introspection (`claude` or `codex`); otherwise the provider the owner chats on, then `DEFAULT_PROVIDER` |
|
|
476
477
|
| `DREAM_MODEL_CLAUDE` / `DREAM_MODEL_CODEX` | No | Exact per-provider dream model override; legacy `DREAM_MODEL` remains a Claude-only compatibility alias |
|
|
477
478
|
| `DREAM_TIER` | No | Provider-owned model tier for the dream pass: `low`, `medium`, or `high` (default) |
|
|
478
479
|
| `DREAM_FULL_SWEEP_DAYS` | No | Days between full-corpus dream sweeps; other nights are cheap deltas over recently-touched packs (default `30`) |
|
package/core/actions.js
CHANGED
|
@@ -918,6 +918,10 @@ async function handleAction(envelope) {
|
|
|
918
918
|
if (d.startsWith("rq:")) {
|
|
919
919
|
return require("./turn-journal").handleRunDropped(d, envelope, { isOwner: isChatOwner(envelope.channelId) });
|
|
920
920
|
}
|
|
921
|
+
// The ✕ on a "Queued." notice: take that message out of the queue.
|
|
922
|
+
if (d.startsWith("qx:")) {
|
|
923
|
+
return require("./runner").handleQueueRemove(d, envelope, { isOwner: isChatOwner(envelope.channelId) });
|
|
924
|
+
}
|
|
921
925
|
if (d.startsWith("grd:")) {
|
|
922
926
|
// Guardrail capture buttons (Track C). Owner-only: standing rules layer
|
|
923
927
|
// above lessons and are never editable by an external speaker.
|
package/core/auth-flow.js
CHANGED
|
@@ -109,7 +109,12 @@ function looksLikeOpenAIKey(value) {
|
|
|
109
109
|
|
|
110
110
|
function isClaudeAuthErrorText(text) {
|
|
111
111
|
const lower = String(text || "").toLowerCase();
|
|
112
|
-
|
|
112
|
+
// `claude auth status` prints JSON in Claude Code 2.1: signed out it is
|
|
113
|
+
// {"loggedIn": false, "authMethod": "none"} (exit 1), which no phrase below
|
|
114
|
+
// matched, so a signed-out Claude read as "unknown" and was still chosen
|
|
115
|
+
// (9 Oct 2026: the dream of every Codex-only bot ran on Claude).
|
|
116
|
+
return /"loggedin"\s*:\s*false/.test(lower) ||
|
|
117
|
+
lower.includes("unauthorized") ||
|
|
113
118
|
lower.includes("not logged in") ||
|
|
114
119
|
lower.includes("login required") ||
|
|
115
120
|
lower.includes("reauthenticate") ||
|
|
@@ -145,7 +150,7 @@ function summarizeAuthOutput(output, ok) {
|
|
|
145
150
|
function summarizeClaudeAuthStatus(output, exitCode, tokenInfo) {
|
|
146
151
|
const text = String(output || "");
|
|
147
152
|
const lower = text.toLowerCase();
|
|
148
|
-
const loggedOut = /not (logged in|authenticated)|unauthenticated|no auth|login required/.test(lower);
|
|
153
|
+
const loggedOut = /not (logged in|authenticated)|unauthenticated|no auth|login required|"loggedin"\s*:\s*false/.test(lower);
|
|
149
154
|
const loggedIn = !loggedOut && (exitCode === 0 || /logged in|authenticated|claude\.ai|anthropic/.test(lower));
|
|
150
155
|
const email = (text.match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i) || [null])[0];
|
|
151
156
|
const provider = /claude\.ai|claudeai/.test(lower) ? "Claude.ai" : (/anthropic/.test(lower) ? "Anthropic" : "unknown");
|
package/core/dream.js
CHANGED
|
@@ -26,6 +26,7 @@ const daySeeds = require("./day-seeds");
|
|
|
26
26
|
const packGuard = require("./pack-guard");
|
|
27
27
|
const persona = require("./persona");
|
|
28
28
|
const { spawnUtilityAgent } = require("./utility-agent");
|
|
29
|
+
const usageLimits = require("./usage-limits");
|
|
29
30
|
const recallMetrics = require("./recall/metrics");
|
|
30
31
|
const recallTuning = require("./recall/tuning");
|
|
31
32
|
const recallGraphLib = require("./recall/graph");
|
|
@@ -1117,6 +1118,40 @@ function failedIntelligenceMetadata(error) {
|
|
|
1117
1118
|
};
|
|
1118
1119
|
}
|
|
1119
1120
|
|
|
1121
|
+
// Waits in a row for a limit (a reset that is used up again at once) before
|
|
1122
|
+
// the dream gives up until its next nightly run.
|
|
1123
|
+
const DREAM_LIMIT_MAX_WAITS = 5;
|
|
1124
|
+
|
|
1125
|
+
// A dream AI step a usage limit stopped waits for the reset, like a chat
|
|
1126
|
+
// message or a scheduled run (core/usage-limits limitRetryAt), never on
|
|
1127
|
+
// another provider. Null when the failure was not a limit, or its reset is
|
|
1128
|
+
// unknown or too far out to wait for.
|
|
1129
|
+
function dreamLimitWait(error, { now = Date.now() } = {}) {
|
|
1130
|
+
const result = error?.result || null;
|
|
1131
|
+
if ((error?.code || result?.error?.code) !== "PROVIDER_USAGE_LIMIT") return null;
|
|
1132
|
+
const provider = result?.provider || null;
|
|
1133
|
+
const hit = usageLimits.limitHitDetails({ provider, error: result?.error || error, now });
|
|
1134
|
+
const runAt = usageLimits.limitRetryAt(hit?.resetsAt, { now });
|
|
1135
|
+
if (!hit || runAt === null || !usageLimits.canWaitFor(runAt, { now })) return null;
|
|
1136
|
+
return { provider, limitType: hit.limitType || null, resetsAt: hit.resetsAt, runAt };
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
function limitName(provider, limitType) {
|
|
1140
|
+
return `${usageLimits.providerLabel(provider)}'s ${usageLimits.windowLabel(limitType)}`;
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
// The "Heads up — <note>, so I skipped the AI-led merges" note of an AI step
|
|
1144
|
+
// that failed for good: plain words for a sign-in that died or a limit with
|
|
1145
|
+
// no reset, the provider's own message otherwise.
|
|
1146
|
+
function dreamFailNote(error) {
|
|
1147
|
+
const result = error?.result || null;
|
|
1148
|
+
const code = error?.code || result?.error?.code;
|
|
1149
|
+
const label = usageLimits.providerLabel(result?.provider);
|
|
1150
|
+
if (code === "PROVIDER_UNAUTHENTICATED") return `${label}'s sign-in on this bot has expired (send /login to sign in again)`;
|
|
1151
|
+
if (code === "PROVIDER_USAGE_LIMIT") return `${label}'s usage limit is used up and it gave no reset time`;
|
|
1152
|
+
return `the AI consolidation step didn't finish (${error?.message || "unknown error"})`;
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1120
1155
|
async function previewDreamWith(runUtility, { provider = null } = {}) {
|
|
1121
1156
|
const result = await runUtility(buildDreamPrompt(), dreamUtilityOptions("dream", {
|
|
1122
1157
|
provider,
|
|
@@ -1189,6 +1224,14 @@ async function runDreamMutation({ trigger = "manual", provider = null, runUtilit
|
|
|
1189
1224
|
// decision (applyDream no-ops), note it for the report, and carry on.
|
|
1190
1225
|
let decision = null;
|
|
1191
1226
|
let modelFailNote = "";
|
|
1227
|
+
let limitWait = null;
|
|
1228
|
+
// Waits in a row so far: a run after a reset that is used up again counts on.
|
|
1229
|
+
const waitsBefore = trigger === "limit-reset" ? Number(dreamState.limitWait?.attempts) || 0 : 0;
|
|
1230
|
+
const limitWaitFor = (error) => {
|
|
1231
|
+
const wait = dreamLimitWait(error);
|
|
1232
|
+
if (!wait || waitsBefore + 1 <= DREAM_LIMIT_MAX_WAITS) return { wait, note: "" };
|
|
1233
|
+
return { wait: null, note: `${limitName(wait.provider, wait.limitType)} is still used up after ${DREAM_LIMIT_MAX_WAITS} waits (the next nightly dream tries again)` };
|
|
1234
|
+
};
|
|
1192
1235
|
let consolidationIntelligence = intelligenceMetadata(null, "not_run");
|
|
1193
1236
|
try {
|
|
1194
1237
|
const dreamRun = await runUtility(
|
|
@@ -1208,7 +1251,10 @@ async function runDreamMutation({ trigger = "manual", provider = null, runUtilit
|
|
|
1208
1251
|
}
|
|
1209
1252
|
} catch (e) {
|
|
1210
1253
|
consolidationIntelligence = failedIntelligenceMetadata(e);
|
|
1211
|
-
|
|
1254
|
+
const waiting = limitWaitFor(e);
|
|
1255
|
+
limitWait = waiting.wait;
|
|
1256
|
+
if (limitWait) consolidationIntelligence.status = "waiting_for_limit";
|
|
1257
|
+
else modelFailNote = waiting.note || dreamFailNote(e);
|
|
1212
1258
|
}
|
|
1213
1259
|
if (!decision) decision = { report: "" };
|
|
1214
1260
|
|
|
@@ -1266,7 +1312,9 @@ async function runDreamMutation({ trigger = "manual", provider = null, runUtilit
|
|
|
1266
1312
|
catch (e) { console.warn(`[dream] certification proposals failed: ${e.message}`); }
|
|
1267
1313
|
const consolidationLines = applied.concat(nestLines, journalLines, coRescueLines, hebbianLines, prospectiveLines, regressionLines, costLines, certLines, knobLines);
|
|
1268
1314
|
let report = decision.report || (consolidationLines.length > 0 ? "Tidied up my memory overnight." : "");
|
|
1269
|
-
if (
|
|
1315
|
+
if (limitWait) {
|
|
1316
|
+
report = `⏳ ${limitName(limitWait.provider, limitWait.limitType)} is used up, so the AI-led merges wait for its reset: I'll run them ${usageLimits.whenPhrase(limitWait.runAt)}. The automatic filing + graph upkeep ran now.${report ? " " + report : ""}`;
|
|
1317
|
+
} else if (modelFailNote) {
|
|
1270
1318
|
report = `Heads up — ${modelFailNote}, so I skipped the AI-led merges this round but still ran the automatic filing + graph upkeep.${report ? " " + report : ""}`;
|
|
1271
1319
|
}
|
|
1272
1320
|
|
|
@@ -1278,8 +1326,9 @@ async function runDreamMutation({ trigger = "manual", provider = null, runUtilit
|
|
|
1278
1326
|
let introReport = "";
|
|
1279
1327
|
let introApplied = [];
|
|
1280
1328
|
let introProposed = null;
|
|
1281
|
-
let introspectionIntelligence = intelligenceMetadata(null, introspectEnabled() ? "not_run" : "disabled");
|
|
1282
|
-
|
|
1329
|
+
let introspectionIntelligence = intelligenceMetadata(null, introspectEnabled() ? (limitWait ? "waiting_for_limit" : "not_run") : "disabled");
|
|
1330
|
+
// A used-up limit stops this step too: it runs with the dream after the reset.
|
|
1331
|
+
if (introspectEnabled() && !limitWait) {
|
|
1283
1332
|
try {
|
|
1284
1333
|
const introRun = await runUtility(buildIntrospectionPrompt(), dreamUtilityOptions("introspection", {
|
|
1285
1334
|
provider,
|
|
@@ -1300,6 +1349,11 @@ async function runDreamMutation({ trigger = "manual", provider = null, runUtilit
|
|
|
1300
1349
|
}
|
|
1301
1350
|
} catch (e) {
|
|
1302
1351
|
introspectionIntelligence = failedIntelligenceMetadata(e);
|
|
1352
|
+
limitWait = limitWaitFor(e).wait;
|
|
1353
|
+
if (limitWait) {
|
|
1354
|
+
introspectionIntelligence.status = "waiting_for_limit";
|
|
1355
|
+
introReport = `⏳ ${limitName(limitWait.provider, limitWait.limitType)} ran out during my self-review: I'll run the dream again ${usageLimits.whenPhrase(limitWait.runAt)}, when it resets.`;
|
|
1356
|
+
}
|
|
1303
1357
|
console.warn(`[dream] introspection phase failed: ${e.message}`);
|
|
1304
1358
|
}
|
|
1305
1359
|
}
|
|
@@ -1493,6 +1547,12 @@ async function runDreamMutation({ trigger = "manual", provider = null, runUtilit
|
|
|
1493
1547
|
dreamState.fallbackUsed = consolidationIntelligence.fallbackUsed;
|
|
1494
1548
|
dreamState.lastRunStatus = consolidationIntelligence.status;
|
|
1495
1549
|
dreamState.introspection = introspectionIntelligence;
|
|
1550
|
+
// The AI step waits for a limit's reset (createDreamLimitWait runs the
|
|
1551
|
+
// dream then); a run that got past the limit, or failed another way,
|
|
1552
|
+
// leaves nothing waiting. Waits in a row are counted and capped.
|
|
1553
|
+
dreamState.limitWait = limitWait
|
|
1554
|
+
? { ...limitWait, attempts: waitsBefore + 1, since: (waitsBefore && dreamState.limitWait?.since) || nowISO }
|
|
1555
|
+
: null;
|
|
1496
1556
|
writeDreamState(dreamState);
|
|
1497
1557
|
} catch (e) { console.warn(`[dream] state write failed: ${e.message}`); }
|
|
1498
1558
|
|
|
@@ -1553,6 +1613,7 @@ async function runDreamMutation({ trigger = "manual", provider = null, runUtilit
|
|
|
1553
1613
|
introspectionProvider: introspectionIntelligence.provider,
|
|
1554
1614
|
introspectionModel: introspectionIntelligence.model,
|
|
1555
1615
|
introspectionFallbackUsed: introspectionIntelligence.fallbackUsed,
|
|
1616
|
+
limitWait,
|
|
1556
1617
|
};
|
|
1557
1618
|
}
|
|
1558
1619
|
|
|
@@ -1622,10 +1683,11 @@ async function dreamSummaryTarget(adapters, deps = {}) {
|
|
|
1622
1683
|
return null;
|
|
1623
1684
|
}
|
|
1624
1685
|
|
|
1625
|
-
// One scheduled dream (cron
|
|
1686
|
+
// One scheduled dream (cron, catch-up, or after a limit's reset) with its summary.
|
|
1626
1687
|
async function runScheduledDream(trigger, adapters, deps = {}) {
|
|
1627
1688
|
try {
|
|
1628
1689
|
const res = await (deps.runDream || runDream)({ trigger });
|
|
1690
|
+
try { deps.afterRun?.(res); } catch (_) { /* arming the next wait is best effort */ }
|
|
1629
1691
|
if (res.skipped) { console.log(`dream: skipped — ${res.skipped}`); return res; }
|
|
1630
1692
|
console.log(`dream: applied ${(res.applied || []).length} change(s)`);
|
|
1631
1693
|
if (!summaryEnabled()) { console.log("dream: summary muted (DREAM_SUMMARY=off)"); return res; }
|
|
@@ -1670,6 +1732,63 @@ function createDreamCatchUp(adapters, deps = {}) {
|
|
|
1670
1732
|
};
|
|
1671
1733
|
}
|
|
1672
1734
|
|
|
1735
|
+
// The dream whose AI step a usage limit stopped runs again after the reset
|
|
1736
|
+
// (dream state limitWait.runAt), as a whole dream: its automatic steps are
|
|
1737
|
+
// safe to repeat. Armed at boot, after every scheduled run, and hourly (a
|
|
1738
|
+
// manual /dream may have recorded a wait), so a restart never loses it.
|
|
1739
|
+
function createDreamLimitWait(adapters, deps = {}) {
|
|
1740
|
+
const now = deps.now || Date.now;
|
|
1741
|
+
const readState = deps.readState || readDreamState;
|
|
1742
|
+
const setTimer = deps.setTimer || setTimeout;
|
|
1743
|
+
const clearTimer = deps.clearTimer || clearTimeout;
|
|
1744
|
+
const isDreaming = deps.isDreaming || defaultDreamService.isDreaming;
|
|
1745
|
+
// setTimeout's ceiling is ~24.8 days; a week's wait fits, the rest re-arms.
|
|
1746
|
+
const MAX_TIMER_MS = 2 ** 31 - 1;
|
|
1747
|
+
let timer = null;
|
|
1748
|
+
let armedFor = null;
|
|
1749
|
+
|
|
1750
|
+
function disarm() {
|
|
1751
|
+
if (timer) clearTimer(timer);
|
|
1752
|
+
timer = null;
|
|
1753
|
+
armedFor = null;
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
function arm() {
|
|
1757
|
+
let runAt = NaN;
|
|
1758
|
+
try { runAt = Number(readState()?.limitWait?.runAt); } catch (_) { runAt = NaN; }
|
|
1759
|
+
if (!Number.isFinite(runAt)) { disarm(); return null; }
|
|
1760
|
+
if (timer && armedFor === runAt) return runAt;
|
|
1761
|
+
disarm();
|
|
1762
|
+
armedFor = runAt;
|
|
1763
|
+
timer = setTimer(() => { fire().catch((e) => console.warn(`dream: limit-reset run failed: ${e.message}`)); }, Math.min(Math.max(runAt - now(), 0), MAX_TIMER_MS));
|
|
1764
|
+
timer?.unref?.();
|
|
1765
|
+
console.log(`dream: AI step waits for a usage limit; runs ${new Date(runAt).toISOString()}`);
|
|
1766
|
+
return runAt;
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1769
|
+
async function fire() {
|
|
1770
|
+
timer = null;
|
|
1771
|
+
armedFor = null;
|
|
1772
|
+
let wait = null;
|
|
1773
|
+
try { wait = readState()?.limitWait || null; } catch (_) { wait = null; }
|
|
1774
|
+
const runAt = Number(wait?.runAt);
|
|
1775
|
+
if (!Number.isFinite(runAt)) return { skipped: "nothing waits" };
|
|
1776
|
+
if (runAt - now() > 1000) return { skipped: "not yet", runAt: arm() };
|
|
1777
|
+
if (isDreaming()) {
|
|
1778
|
+
// A dream is running now: look again in a minute.
|
|
1779
|
+
timer = setTimer(() => { fire().catch(() => {}); }, 60000);
|
|
1780
|
+
timer?.unref?.();
|
|
1781
|
+
armedFor = runAt;
|
|
1782
|
+
return { skipped: "a dream is already running" };
|
|
1783
|
+
}
|
|
1784
|
+
const res = await runScheduledDream("limit-reset", adapters, deps);
|
|
1785
|
+
arm();
|
|
1786
|
+
return res;
|
|
1787
|
+
}
|
|
1788
|
+
|
|
1789
|
+
return { arm, fire, disarm };
|
|
1790
|
+
}
|
|
1791
|
+
|
|
1673
1792
|
// In-bot scheduler: one quiet-hours cron (tolerating a late start up to
|
|
1674
1793
|
// DREAM_LATE_TOLERANCE_MS, see core/cron-schedule), a boot + hourly catch-up
|
|
1675
1794
|
// check, and the summary to the owner's channel (dreamSummaryTarget). When the
|
|
@@ -1685,18 +1804,20 @@ function initDream(adapters, deps = {}) {
|
|
|
1685
1804
|
}
|
|
1686
1805
|
const { DREAM_LATE_TOLERANCE_MS } = require("./config");
|
|
1687
1806
|
const toleranceMs = Number.isFinite(deps.toleranceMs) ? deps.toleranceMs : DREAM_LATE_TOLERANCE_MS;
|
|
1688
|
-
const
|
|
1807
|
+
const limitWait = createDreamLimitWait(adapters, deps);
|
|
1808
|
+
const runDeps = { ...deps, afterRun: () => limitWait.arm() };
|
|
1809
|
+
const task = scheduleTolerant(DREAM_CRON, () => runScheduledDream("cron", adapters, runDeps), {
|
|
1689
1810
|
cron: cronLib, toleranceMs, noOverlap: true, label: "dream",
|
|
1690
1811
|
});
|
|
1691
|
-
const catchUp = createDreamCatchUp(adapters,
|
|
1812
|
+
const catchUp = createDreamCatchUp(adapters, runDeps);
|
|
1692
1813
|
const setTimer = deps.setTimer || setTimeout;
|
|
1693
1814
|
const setRepeat = deps.setInterval || setInterval;
|
|
1694
|
-
const boot = setTimer(() => { catchUp("boot").catch(() => {}); }, Number.isFinite(deps.bootDelayMs) ? deps.bootDelayMs : 60000);
|
|
1695
|
-
const hourly = setRepeat(() => { catchUp("hourly").catch(() => {}); }, 3600000);
|
|
1815
|
+
const boot = setTimer(() => { limitWait.arm(); catchUp("boot").catch(() => {}); }, Number.isFinite(deps.bootDelayMs) ? deps.bootDelayMs : 60000);
|
|
1816
|
+
const hourly = setRepeat(() => { limitWait.arm(); catchUp("hourly").catch(() => {}); }, 3600000);
|
|
1696
1817
|
boot?.unref?.();
|
|
1697
1818
|
hourly?.unref?.();
|
|
1698
1819
|
console.log(`dream: scheduled (${DREAM_CRON}, late tolerance ${Math.round(toleranceMs / 60000)} min, provider policy tier ${dreamTier()}, effort ${DREAM_EFFORT}, introspect ${introspectEnabled() ? "on" : "off"})`);
|
|
1699
|
-
return { task, catchUp };
|
|
1820
|
+
return { task, catchUp, limitWait };
|
|
1700
1821
|
}
|
|
1701
1822
|
|
|
1702
1823
|
module.exports = {
|
|
@@ -1704,6 +1825,7 @@ module.exports = {
|
|
|
1704
1825
|
buildIntrospectionPrompt, parseIntrospection, applyIntrospection, writeDreamReport,
|
|
1705
1826
|
summaryDelta, healthOf, mergeClusters, selectDumpDirs, journalBackfillDedupe, staleTaskReport,
|
|
1706
1827
|
dreamSummaryTarget, runScheduledDream, dreamOverdue, createDreamCatchUp, DREAM_CATCHUP_MS,
|
|
1828
|
+
createDreamLimitWait, dreamLimitWait, dreamFailNote, DREAM_LIMIT_MAX_WAITS,
|
|
1707
1829
|
coRescueEdgesPhase, negativeHebbianPhase, readDreamState, writeDreamState,
|
|
1708
1830
|
enabled, summaryEnabled, introspectEnabled, selfApplyEnabled, dreamTier,
|
|
1709
1831
|
DREAM_CRON, DREAM_EFFORT, DREAM_OUTPUT_SCHEMA, INTROSPECTION_OUTPUT_SCHEMA,
|
package/core/handlers.js
CHANGED
|
@@ -118,6 +118,8 @@ function dropLaneQueue(lane, reason) {
|
|
|
118
118
|
// Clears the CURRENT conversation's queue only: /new, /end or a project change
|
|
119
119
|
// in one thread must not drop what another conversation has waiting.
|
|
120
120
|
function clearTransientQueue(state, reason = "Session context changed before the queued run started") {
|
|
121
|
+
// The stopped turn's history goes with the session: nothing to warn about.
|
|
122
|
+
if (state) { try { require("./turn-journal").clearStopped(currentLaneKey(state)); } catch (_) {} }
|
|
121
123
|
return dropLaneQueue(state ? peekLane(state) : null, reason).length;
|
|
122
124
|
}
|
|
123
125
|
|
|
@@ -1739,6 +1741,18 @@ function stopLane(state, lane) {
|
|
|
1739
1741
|
return null;
|
|
1740
1742
|
}
|
|
1741
1743
|
|
|
1744
|
+
// stopLane for /stop: a run killed mid-turn left its request in the session's
|
|
1745
|
+
// history, so the conversation's next turn is told it was cancelled (core/
|
|
1746
|
+
// turn-journal markStopped). A compaction or a turn still preparing left none.
|
|
1747
|
+
function stopAndMarkLane(state, lane) {
|
|
1748
|
+
const killed = lane.runningProcess && lane.activeRunContext ? lane.activeRunContext : null;
|
|
1749
|
+
const stopped = stopLane(state, lane);
|
|
1750
|
+
if (stopped && killed) {
|
|
1751
|
+
try { require("./turn-journal").markStopped(lane.key, { runContext: killed }); } catch (_) {}
|
|
1752
|
+
}
|
|
1753
|
+
return stopped;
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1742
1756
|
const STOP_DROP_REASON = "Dropped by /stop before it started";
|
|
1743
1757
|
|
|
1744
1758
|
// /stop never silently loses a queued message: each dropped one comes back
|
|
@@ -1803,7 +1817,7 @@ register({
|
|
|
1803
1817
|
const lanes = state.lanes instanceof Map ? [...state.lanes.values()] : [];
|
|
1804
1818
|
// Drop the queues first, so no stopped lane's unwind admits them.
|
|
1805
1819
|
const dropped = lanes.flatMap((lane) => dropLaneQueue(lane, STOP_DROP_REASON));
|
|
1806
|
-
const stopped = lanes.filter((lane) =>
|
|
1820
|
+
const stopped = lanes.filter((lane) => stopAndMarkLane(state, lane)).length;
|
|
1807
1821
|
const sideStopped = await sideCancellation;
|
|
1808
1822
|
let pickups = [];
|
|
1809
1823
|
try { pickups = require("./limit-pickup").cancelAllFor(state.userId, "/stop all"); } catch (_) {}
|
|
@@ -1827,7 +1841,7 @@ register({
|
|
|
1827
1841
|
// A pick-up waiting for a usage limit to reset is dropped too.
|
|
1828
1842
|
const pickup = dropLimitPickup("/stop", state, lane.key);
|
|
1829
1843
|
const pickupNote = pickup ? ` ${pickupDroppedNote(1)}` : "";
|
|
1830
|
-
if (
|
|
1844
|
+
if (stopAndMarkLane(state, lane)) {
|
|
1831
1845
|
await sideCancellation;
|
|
1832
1846
|
await send(`${dropped.length ? `Cancelled. ${droppedNote(dropped, "queued")}` : "Cancelled."}${pickupNote}`, dropOffer(dropped));
|
|
1833
1847
|
} else if (dropped.length) {
|
package/core/limit-pickup.js
CHANGED
|
@@ -17,6 +17,9 @@
|
|
|
17
17
|
// pick it up" prompt. If that run hits a limit again it re-arms, keeping
|
|
18
18
|
// the original message and counting attempts (MAX_ATTEMPTS at most).
|
|
19
19
|
// LIMIT_PICKUP=off turns it off (the notice then only says when it resets).
|
|
20
|
+
// When to run again is one rule for every kind of work (usage-limits
|
|
21
|
+
// limitRetryAt); a scheduled run a limit stopped waits the same way in the
|
|
22
|
+
// jobs store (core/scheduler.js), with the notice worded here.
|
|
20
23
|
|
|
21
24
|
const path = require("path");
|
|
22
25
|
const crypto = require("crypto");
|
|
@@ -37,16 +40,10 @@ function setting(name) {
|
|
|
37
40
|
function enabled() {
|
|
38
41
|
return !/^(off|false|0|no)$/i.test(String(setting("LIMIT_PICKUP") ?? "on").trim());
|
|
39
42
|
}
|
|
40
|
-
//
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
}
|
|
43
|
+
// The shared rule (core/usage-limits.js): LIMIT_PICKUP_DELAY_S after the
|
|
44
|
+
// reset, never for a reset more than LIMIT_PICKUP_MAX_WAIT_H away.
|
|
45
|
+
const delayMs = () => limits.limitRetryDelayMs();
|
|
46
|
+
const maxWaitMs = () => limits.limitMaxWaitMs();
|
|
50
47
|
|
|
51
48
|
// A test run that did not point the store somewhere else never touches the
|
|
52
49
|
// live bot's pick-ups.
|
|
@@ -100,9 +97,10 @@ function limitName(provider, limitType) {
|
|
|
100
97
|
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
98
|
|
|
102
99
|
/**
|
|
103
|
-
* What a turn stopped by a limit should do: arm a pick-up (armable)
|
|
104
|
-
* say when the limit lifts.
|
|
105
|
-
* then calls arm() with the
|
|
100
|
+
* What a turn stopped by a limit should do: arm a pick-up (armable), wait in
|
|
101
|
+
* the scheduler (waits: a scheduled run), or only say when the limit lifts.
|
|
102
|
+
* Pure: deliverForegroundResult posts `notice` and then calls arm() with the
|
|
103
|
+
* plan when armable; the scheduler reads `waits` and `runAt`.
|
|
106
104
|
*/
|
|
107
105
|
function planFor(result, runContext, { now = Date.now(), windows = [], guarded = false } = {}) {
|
|
108
106
|
const hit = limits.limitHitDetails({
|
|
@@ -111,21 +109,27 @@ function planFor(result, runContext, { now = Date.now(), windows = [], guarded =
|
|
|
111
109
|
if (!hit) return null;
|
|
112
110
|
// An outside contact is not told which model or plan the bot runs on.
|
|
113
111
|
const name = guarded ? "My usage limit" : limitName(hit.provider, hit.limitType);
|
|
114
|
-
const
|
|
115
|
-
const
|
|
112
|
+
const runAt = limits.limitRetryAt(hit.resetsAt, { now });
|
|
113
|
+
const known = runAt !== null;
|
|
116
114
|
const pickup = runContext?.limitPickup || null;
|
|
117
115
|
const attempts = (pickup?.attempts || 0) + 1;
|
|
116
|
+
const waitable = known && limits.canWaitFor(runAt, { now }) && attempts <= MAX_ATTEMPTS;
|
|
117
|
+
// A scheduled run waits in the jobs store for the reset (core/scheduler.js).
|
|
118
|
+
const waits = waitable && runContext?.purpose === "scheduled";
|
|
118
119
|
const armable = enabled()
|
|
119
|
-
&&
|
|
120
|
-
&& runAt - now <= maxWaitMs()
|
|
121
|
-
&& attempts <= MAX_ATTEMPTS
|
|
120
|
+
&& waitable
|
|
122
121
|
&& runContext?.purpose === "foreground"
|
|
123
122
|
&& !runContext?.passive
|
|
124
123
|
&& !runContext?.liveCall
|
|
125
124
|
&& !!runContext?.origin?.adapterId
|
|
126
125
|
&& !!runContext?.channelId;
|
|
127
126
|
let notice;
|
|
128
|
-
if (
|
|
127
|
+
if (waits) {
|
|
128
|
+
const again = pickup && pickup.limitType && pickup.limitType === hit.limitType ? "is still used up" : "is used up too";
|
|
129
|
+
notice = pickup
|
|
130
|
+
? `⏳ ${name} ${again}. I'll try this scheduled run again ${at(runAt, now)}.`
|
|
131
|
+
: `⏳ ${name} is used up, so this scheduled run waits. I'll run it ${at(runAt, now)}, when it resets.`;
|
|
132
|
+
} else if (known && runContext?.purpose && runContext.purpose !== "foreground") {
|
|
129
133
|
notice = `⏳ ${name} is used up, so this ${runContext.purpose === "scheduled" ? "scheduled run" : "run"} stopped. It resets ${at(hit.resetsAt, now)}.`;
|
|
130
134
|
} else if (armable) {
|
|
131
135
|
const again = pickup && pickup.limitType && pickup.limitType === hit.limitType ? "is still used up" : "is used up too";
|
|
@@ -141,7 +145,7 @@ function planFor(result, runContext, { now = Date.now(), windows = [], guarded =
|
|
|
141
145
|
} else {
|
|
142
146
|
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
147
|
}
|
|
144
|
-
return { hit, armable, runAt, attempts, notice };
|
|
148
|
+
return { hit, armable, waits, runAt, attempts, notice };
|
|
145
149
|
}
|
|
146
150
|
|
|
147
151
|
// ── arm / cancel ────────────────────────────────────────────────────────
|
package/core/providers/claude.js
CHANGED
|
@@ -18,15 +18,22 @@ const { createClaudeHookTransport } = require("./claude-hook");
|
|
|
18
18
|
// consulting this list, so `/model claude-opus-4-8` still works as a rollback.
|
|
19
19
|
// The list is consulted on tapped mb: callbacks (core/actions.js), whose job is
|
|
20
20
|
// to reject a stale button from an old message — that is the intended effect.
|
|
21
|
-
// claude-sonnet-
|
|
22
|
-
// utility work (see tierModel below), not just picker entries.
|
|
21
|
+
// claude-sonnet-5-5 and claude-haiku-5-5 STAY: they are the tier models for
|
|
22
|
+
// utility work (see tierModel below), not just picker entries. Haiku 5.5
|
|
23
|
+
// replaced Haiku 4.5 as the low tier on 9 Oct 2026 (Sumeet: "check if anything
|
|
24
|
+
// using haiku and have it shifted to the new haiku 5.5"), and Sonnet 5.5
|
|
25
|
+
// replaced Sonnet 4.6 as the medium tier the same day ("sonnet should also be
|
|
26
|
+
// just 5.5"); `/model claude-haiku-4-5-20251001` and `/model claude-sonnet-4-6`
|
|
27
|
+
// still work typed.
|
|
28
|
+
const CLAUDE_LOW_TIER_MODEL = "claude-haiku-5-5";
|
|
29
|
+
const CLAUDE_MEDIUM_TIER_MODEL = "claude-sonnet-5-5";
|
|
23
30
|
const CLAUDE_MODELS = Object.freeze([
|
|
24
31
|
"claude-fable-5-1",
|
|
25
32
|
"claude-fable-5",
|
|
26
33
|
"claude-opus-5-5",
|
|
27
34
|
"claude-opus-5",
|
|
28
|
-
|
|
29
|
-
|
|
35
|
+
CLAUDE_MEDIUM_TIER_MODEL,
|
|
36
|
+
CLAUDE_LOW_TIER_MODEL,
|
|
30
37
|
]);
|
|
31
38
|
const CLAUDE_EFFORT_VALUES = Object.freeze(["low", "medium", "high", "xhigh", "max"]);
|
|
32
39
|
// The default governs MAIN turns only: utility work is pinned by tier below
|
|
@@ -99,8 +106,8 @@ function appendModelAndLimits(args, context, defaultModel, utility) {
|
|
|
99
106
|
let model = context.model || settings.model || null;
|
|
100
107
|
if (!model && utility && context.tier) {
|
|
101
108
|
model = context.tier === "low"
|
|
102
|
-
?
|
|
103
|
-
: (context.tier === "medium" ?
|
|
109
|
+
? CLAUDE_LOW_TIER_MODEL
|
|
110
|
+
: (context.tier === "medium" ? CLAUDE_MEDIUM_TIER_MODEL : defaultModel);
|
|
104
111
|
}
|
|
105
112
|
args.push("--model", String(model || defaultModel));
|
|
106
113
|
|
|
@@ -311,8 +318,8 @@ function createClaudeProvider(options = {}) {
|
|
|
311
318
|
},
|
|
312
319
|
nativeToolsNote: "Claude Code exposes its native tools; Open Claudia's tool hook and approval policy remain authoritative.",
|
|
313
320
|
tierModel(tier) {
|
|
314
|
-
if (tier === "low") return
|
|
315
|
-
if (tier === "medium") return
|
|
321
|
+
if (tier === "low") return CLAUDE_LOW_TIER_MODEL;
|
|
322
|
+
if (tier === "medium") return CLAUDE_MEDIUM_TIER_MODEL;
|
|
316
323
|
return defaultModel;
|
|
317
324
|
},
|
|
318
325
|
};
|
package/core/providers/events.js
CHANGED
|
@@ -127,17 +127,40 @@ function createJsonlDecoder(options = {}) {
|
|
|
127
127
|
return api;
|
|
128
128
|
}
|
|
129
129
|
|
|
130
|
+
// What a provider's failure means, read in one place for chat turns
|
|
131
|
+
// (core/runner) and background calls (core/utility-agent):
|
|
132
|
+
// authError — signed out, or the sign-in died. Waiting does not fix it;
|
|
133
|
+
// /login does. Claude Code 2.1 words a dead refresh "Failed to
|
|
134
|
+
// authenticate: OAuth session expired and could not be
|
|
135
|
+
// refreshed"; Codex "Your authentication token has expired".
|
|
136
|
+
// usageLimit — a plan limit. Its reset fixes it.
|
|
137
|
+
// transient — Claude Code's refresh-lock race ("another Claude Code process
|
|
138
|
+
// is refreshing it or exited mid-refresh"): a later try works.
|
|
139
|
+
// Four fleet bots lost the 9 Oct 07:00 dream to it.
|
|
130
140
|
function providerErrorFlags(message) {
|
|
131
141
|
const text = String(message || "").toLowerCase();
|
|
142
|
+
const transient = /another claude code process is refreshing|exited mid-refresh/.test(text);
|
|
132
143
|
return {
|
|
133
|
-
authError: /unauthori[sz]ed|not (?:logged in|authenticated)|unauthenticated|login required|authentication failed|auth failed|invalid api key|no credentials|401/.test(text),
|
|
144
|
+
authError: !transient && /unauthori[sz]ed|not (?:logged in|authenticated)|unauthenticated|login required|authentication failed|auth failed|failed to authenticate|invalid api key|no credentials|401|oauth (?:session|access token|token) (?:expired|has expired|is invalid|is no longer valid)|could not be refreshed|refresh token is no longer valid|authentication token (?:has )?expired/.test(text),
|
|
134
145
|
// "You've hit your limit / session limit / weekly limit / Opus limit · resets …"
|
|
135
146
|
// is how Claude Code words a plan limit; the older patterns never matched it.
|
|
136
147
|
usageLimit: /usage limit|spend limit|monthly cycle|quota|rate limit|hit your (?:[a-z0-9'’-]+ ){0,3}limit/.test(text),
|
|
148
|
+
transient,
|
|
137
149
|
refusal: /refusals-and-fallback|usage policy|safety classifier|stop_reason['":\s]*refusal/.test(text),
|
|
138
150
|
};
|
|
139
151
|
}
|
|
140
152
|
|
|
153
|
+
// The error code of a failed run, from its normalized error event's flags.
|
|
154
|
+
// Chat turns pass PROVIDER_EVENT_ERROR as the fallback, background calls
|
|
155
|
+
// UTILITY_PROVIDER_ERROR, so both read a limit or a dead sign-in the same way.
|
|
156
|
+
function providerFailureCode(event, fallback = "PROVIDER_EVENT_ERROR") {
|
|
157
|
+
if (event?.refusal) return "PROVIDER_REFUSAL";
|
|
158
|
+
if (event?.authError) return "PROVIDER_UNAUTHENTICATED";
|
|
159
|
+
if (event?.usageLimit) return "PROVIDER_USAGE_LIMIT";
|
|
160
|
+
if (event?.transient) return "PROVIDER_TRANSIENT";
|
|
161
|
+
return fallback;
|
|
162
|
+
}
|
|
163
|
+
|
|
141
164
|
// The CLI flattens the API's stop_details into the error text; recover the
|
|
142
165
|
// human-readable pieces so the chat message can lead with the actual reason.
|
|
143
166
|
function refusalDetailsFromMessage(message) {
|
|
@@ -184,6 +207,7 @@ module.exports = {
|
|
|
184
207
|
normalizeDecoderError,
|
|
185
208
|
normalizedUsage,
|
|
186
209
|
providerErrorFlags,
|
|
210
|
+
providerFailureCode,
|
|
187
211
|
refusalDetailsFromMessage,
|
|
188
212
|
selectTerminalText,
|
|
189
213
|
usageFingerprint,
|