@inetafrica/open-claudia 3.5.16 → 3.5.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +10 -0
- package/CHANGELOG.md +53 -0
- package/README.md +4 -0
- package/bin/cli.js +1 -1
- package/bin/task.js +51 -0
- package/bot.js +26 -0
- package/channels/kazee/adapter.js +130 -21
- package/channels/telegram/adapter.js +4 -2
- package/core/actions.js +8 -0
- package/core/config.js +22 -1
- package/core/cost-report.js +3 -0
- package/core/cron-schedule.js +59 -0
- package/core/dream.js +141 -34
- package/core/guardrail.js +5 -0
- package/core/handlers.js +78 -17
- package/core/lessons.js +11 -5
- package/core/limit-pickup.js +313 -0
- package/core/loopback.js +29 -0
- package/core/providers/claude-events.js +79 -0
- package/core/providers/codex.js +48 -0
- package/core/providers/events.js +4 -1
- package/core/relationship.js +1 -1
- package/core/router.js +16 -1
- package/core/run-context.js +3 -0
- package/core/runner.js +440 -14
- package/core/safe-filename.js +47 -0
- package/core/scheduler.js +170 -38
- package/core/state.js +91 -0
- package/core/system-prompt.js +89 -16
- package/core/tasks.js +208 -6
- package/core/turn-journal.js +508 -0
- package/core/usage-limits.js +321 -0
- package/core/utility-agent.js +17 -1
- package/package.json +4 -4
- package/test-fixtures/fake-agent-cli.js +26 -2
- package/test-guardrail.js +19 -0
- package/test-kazee-context-media.js +1 -1
- package/test-provider-compaction.js +66 -1
- package/test-provider-dream.js +3 -1
- package/test-provider-events.js +106 -0
- package/test-provider-language.js +16 -1
- package/test-provider-pack-review.js +3 -1
- package/test-provider-scheduler.js +72 -1
- package/test-thread-lanes.js +6 -1
- package/test-usage-accounting.js +4 -0
- package/web.js +104 -51
|
@@ -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
|
+
};
|
package/core/loopback.js
CHANGED
|
@@ -91,12 +91,14 @@ const OWNER_ONLY_KINDS = new Set([
|
|
|
91
91
|
"people-list", "people-show", "people-note",
|
|
92
92
|
"task-add", "task-plan", "task-update", "task-remove",
|
|
93
93
|
"task-list", "task-get", "task-tree", "task-clear-completed",
|
|
94
|
+
"task-archive", "task-restore", "task-archived",
|
|
94
95
|
]);
|
|
95
96
|
const JSON_KINDS = new Set([
|
|
96
97
|
"whoami",
|
|
97
98
|
"schedule-wakeup", "cron-add", "cron-remove", "job-list",
|
|
98
99
|
"watch-add", "watch-list", "watch-remove", "watch-toggle",
|
|
99
100
|
"task-add", "task-plan", "task-update", "task-remove", "task-list", "task-get", "task-tree", "task-clear-completed",
|
|
101
|
+
"task-archive", "task-restore", "task-archived",
|
|
100
102
|
"people-list", "people-show", "people-add", "people-note", "people-link", "people-unlink",
|
|
101
103
|
"people-remove", "people-set-primary",
|
|
102
104
|
"intros-list", "intros-approve", "intros-reject",
|
|
@@ -668,9 +670,12 @@ async function handleJson(req, res, url, kind) {
|
|
|
668
670
|
if (kind === "task-add") {
|
|
669
671
|
if (!payload.content) return reply(res, 400, { error: "missing content" });
|
|
670
672
|
try {
|
|
673
|
+
// A task belongs to the conversation it was made in (the run's thread,
|
|
674
|
+
// null on the main timeline); a subtask follows its plan.
|
|
671
675
|
const t = tasksStore.add(adapterId, channelId, payload.content, {
|
|
672
676
|
parentId: payload.parentId || null,
|
|
673
677
|
description: payload.description || null,
|
|
678
|
+
threadId: threadForCaller(callerOf(payload), channelId, payload.threadId),
|
|
674
679
|
});
|
|
675
680
|
return reply(res, 200, { ok: true, task: t });
|
|
676
681
|
} catch (e) { return reply(res, 400, { error: e.message }); }
|
|
@@ -682,6 +687,7 @@ async function handleJson(req, res, url, kind) {
|
|
|
682
687
|
if (subs.length === 0) return reply(res, 400, { error: "subtasks must be a non-empty array" });
|
|
683
688
|
const result = tasksStore.plan(adapterId, channelId, payload.title, subs, {
|
|
684
689
|
description: payload.description || null,
|
|
690
|
+
threadId: threadForCaller(callerOf(payload), channelId, payload.threadId),
|
|
685
691
|
});
|
|
686
692
|
return reply(res, 200, { ok: true, plan: result });
|
|
687
693
|
}
|
|
@@ -723,6 +729,29 @@ async function handleJson(req, res, url, kind) {
|
|
|
723
729
|
return reply(res, got ? 200 : 404, { ok: !!got, ...(got || {}) });
|
|
724
730
|
}
|
|
725
731
|
|
|
732
|
+
if (kind === "task-archive") {
|
|
733
|
+
if (!payload.id) return reply(res, 400, { error: "missing id" });
|
|
734
|
+
try {
|
|
735
|
+
const result = tasksStore.archive(adapterId, channelId, payload.id, payload.reason);
|
|
736
|
+
return reply(res, result ? 200 : 404, { ok: !!result, ...(result || {}) });
|
|
737
|
+
} catch (e) { return reply(res, 400, { error: e.message }); }
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
if (kind === "task-restore") {
|
|
741
|
+
if (!payload.id) return reply(res, 400, { error: "missing id" });
|
|
742
|
+
try {
|
|
743
|
+
const result = tasksStore.restore(adapterId, channelId, payload.id);
|
|
744
|
+
return reply(res, result ? 200 : 404, { ok: !!result, ...(result || {}) });
|
|
745
|
+
} catch (e) { return reply(res, 400, { error: e.message }); }
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
if (kind === "task-archived") {
|
|
749
|
+
const limit = Number(payload.limit);
|
|
750
|
+
const opts = { search: payload.search || "" };
|
|
751
|
+
if (Number.isFinite(limit) && limit > 0) opts.limit = limit;
|
|
752
|
+
return reply(res, 200, { ok: true, tasks: tasksStore.archived(adapterId, channelId, opts) });
|
|
753
|
+
}
|
|
754
|
+
|
|
726
755
|
if (kind === "task-clear-completed") {
|
|
727
756
|
const remaining = tasksStore.clearCompleted(adapterId, channelId);
|
|
728
757
|
return reply(res, 200, { ok: true, tasks: remaining });
|
|
@@ -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;
|
|
@@ -58,11 +110,27 @@ function normalizeClaudeEvent(rawEvent, state = createClaudeParserState()) {
|
|
|
58
110
|
const decoderFailure = normalizeDecoderError(rawEvent);
|
|
59
111
|
if (decoderFailure) return [decoderFailure];
|
|
60
112
|
|
|
113
|
+
// A helper agent's (Agent/Task tool) own messages carry parent_tool_use_id:
|
|
114
|
+
// its working notes, tool calls and refusals are not this turn's reply.
|
|
115
|
+
// Only its spend still counts.
|
|
116
|
+
if (rawEvent.parent_tool_use_id && ["assistant", "stream_event", "user"].includes(rawEvent.type)) {
|
|
117
|
+
if (rawEvent.type !== "assistant") return [];
|
|
118
|
+
const usage = normalizedUsage(rawEvent.message?.usage, "provider_call", state.seenUsage);
|
|
119
|
+
return usage ? [usage] : [];
|
|
120
|
+
}
|
|
121
|
+
|
|
61
122
|
if (rawEvent.type === "system" && rawEvent.subtype === "init") {
|
|
62
123
|
const event = sessionEvent(rawEvent.session_id, state);
|
|
63
124
|
return event ? [event] : [];
|
|
64
125
|
}
|
|
65
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
|
+
|
|
66
134
|
if (rawEvent.type === "stream_event") {
|
|
67
135
|
if (rawEvent.event?.type === "message_delta") {
|
|
68
136
|
captureRefusal(state, rawEvent.event.delta?.stop_reason, rawEvent.event.delta?.stop_details ?? rawEvent.event.stop_details);
|
|
@@ -76,6 +144,11 @@ function normalizeClaudeEvent(rawEvent, state = createClaudeParserState()) {
|
|
|
76
144
|
}
|
|
77
145
|
|
|
78
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
|
+
}
|
|
79
152
|
const events = [];
|
|
80
153
|
captureRefusal(state, rawEvent.message?.stop_reason, rawEvent.message?.stop_details);
|
|
81
154
|
for (const block of rawEvent.message?.content || []) {
|
|
@@ -124,6 +197,12 @@ function normalizeClaudeEvent(rawEvent, state = createClaudeParserState()) {
|
|
|
124
197
|
if (rawEvent.is_error || /^error/.test(String(rawEvent.subtype || ""))) {
|
|
125
198
|
return [claudeFailureEvent(claudeErrorMessage(rawEvent), state)];
|
|
126
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
|
+
}
|
|
127
206
|
const events = [];
|
|
128
207
|
const session = sessionEvent(rawEvent.session_id, state);
|
|
129
208
|
if (session) events.push(session);
|
package/core/providers/codex.js
CHANGED
|
@@ -75,6 +75,52 @@ function imagePathsOf(context) {
|
|
|
75
75
|
return paths;
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
+
// Codex hands --output-schema to OpenAI in strict mode, which refuses the call
|
|
79
|
+
// (400 invalid_json_schema) when the root is not an object, or an object has no
|
|
80
|
+
// properties, allows extra keys or leaves a property out of `required`
|
|
81
|
+
// (6 Oct 2026: every passive-reply check, pack review, dedup and dream on Codex
|
|
82
|
+
// bots failed that way). An object whose properties are all required is closed
|
|
83
|
+
// with additionalProperties: false, which costs a reply nothing. A schema that
|
|
84
|
+
// needs optional or free-form fields gives null: no schema is sent, and the
|
|
85
|
+
// utility agent still checks the reply against the caller's full schema.
|
|
86
|
+
function strictOutputSchema(schema) {
|
|
87
|
+
function close(node, depth) {
|
|
88
|
+
if (!node || typeof node !== "object" || Array.isArray(node)) return depth > 0 ? node : null;
|
|
89
|
+
if (depth > 20 || node.$ref !== undefined) return null;
|
|
90
|
+
const copy = { ...node };
|
|
91
|
+
if ([].concat(node.type || []).includes("object")) {
|
|
92
|
+
const properties = node.properties && typeof node.properties === "object" ? node.properties : {};
|
|
93
|
+
const keys = Object.keys(properties);
|
|
94
|
+
if (!keys.length) return null;
|
|
95
|
+
if (node.additionalProperties && typeof node.additionalProperties === "object") return null;
|
|
96
|
+
const required = new Set(Array.isArray(node.required) ? node.required : []);
|
|
97
|
+
if (keys.some((key) => !required.has(key))) return null;
|
|
98
|
+
copy.properties = {};
|
|
99
|
+
for (const key of keys) {
|
|
100
|
+
const child = close(properties[key], depth + 1);
|
|
101
|
+
if (child === null) return null;
|
|
102
|
+
copy.properties[key] = child;
|
|
103
|
+
}
|
|
104
|
+
copy.additionalProperties = false;
|
|
105
|
+
}
|
|
106
|
+
if (node.items !== undefined) {
|
|
107
|
+
const items = Array.isArray(node.items) ? null : close(node.items, depth + 1);
|
|
108
|
+
if (items === null) return null;
|
|
109
|
+
copy.items = items;
|
|
110
|
+
}
|
|
111
|
+
for (const key of ["anyOf", "oneOf", "allOf"]) {
|
|
112
|
+
if (node[key] === undefined) continue;
|
|
113
|
+
if (!Array.isArray(node[key])) return null;
|
|
114
|
+
const variants = node[key].map((variant) => close(variant, depth + 1));
|
|
115
|
+
if (variants.includes(null)) return null;
|
|
116
|
+
copy[key] = variants;
|
|
117
|
+
}
|
|
118
|
+
return copy;
|
|
119
|
+
}
|
|
120
|
+
if (!schema || typeof schema !== "object" || schema.type !== "object") return null;
|
|
121
|
+
return close(schema, 0);
|
|
122
|
+
}
|
|
123
|
+
|
|
78
124
|
function createCodexProvider(options = {}) {
|
|
79
125
|
const resolveExecutable = options.resolveExecutable || (() => config.discoverProviderExecutable("codex"));
|
|
80
126
|
const defaultModel = options.defaultModel ?? config.config.CODEX_MODEL ?? null;
|
|
@@ -266,6 +312,7 @@ function createCodexProvider(options = {}) {
|
|
|
266
312
|
return buildInvocation({ ...context, permissionMode: "plan" }, false);
|
|
267
313
|
},
|
|
268
314
|
buildUtilityInvocation: (context) => buildInvocation({ ...context, fresh: true }, true),
|
|
315
|
+
outputSchemaFor: (schema) => strictOutputSchema(schema),
|
|
269
316
|
createEventDecoder: (decoderOptions) => createJsonlDecoder(decoderOptions),
|
|
270
317
|
normalizeEvent: normalizeCodexEvent,
|
|
271
318
|
authStatus: () => getAuthStatus(executable()),
|
|
@@ -300,4 +347,5 @@ module.exports = {
|
|
|
300
347
|
capabilities,
|
|
301
348
|
codexProvider,
|
|
302
349
|
createCodexProvider,
|
|
350
|
+
strictOutputSchema,
|
|
303
351
|
};
|
package/core/providers/events.js
CHANGED
|
@@ -11,6 +11,7 @@ const NORMALIZED_EVENT_TYPES = Object.freeze([
|
|
|
11
11
|
"result",
|
|
12
12
|
"error",
|
|
13
13
|
"warning",
|
|
14
|
+
"rate_limit",
|
|
14
15
|
]);
|
|
15
16
|
|
|
16
17
|
// 16MB: tool results carrying base64 images legitimately exceed 1MB — the old
|
|
@@ -130,7 +131,9 @@ function providerErrorFlags(message) {
|
|
|
130
131
|
const text = String(message || "").toLowerCase();
|
|
131
132
|
return {
|
|
132
133
|
authError: /unauthori[sz]ed|not (?:logged in|authenticated)|unauthenticated|login required|authentication failed|auth failed|invalid api key|no credentials|401/.test(text),
|
|
133
|
-
|
|
134
|
+
// "You've hit your limit / session limit / weekly limit / Opus limit · resets …"
|
|
135
|
+
// is how Claude Code words a plan limit; the older patterns never matched it.
|
|
136
|
+
usageLimit: /usage limit|spend limit|monthly cycle|quota|rate limit|hit your (?:[a-z0-9'’-]+ ){0,3}limit/.test(text),
|
|
134
137
|
refusal: /refusals-and-fallback|usage policy|safety classifier|stop_reason['":\s]*refusal/.test(text),
|
|
135
138
|
};
|
|
136
139
|
}
|
package/core/relationship.js
CHANGED
|
@@ -323,4 +323,4 @@ function ensureContact(envelope) {
|
|
|
323
323
|
}
|
|
324
324
|
}
|
|
325
325
|
|
|
326
|
-
module.exports = { speakerFor, currentSpeaker, guardActive, isCurrentSpeakerGuarded, ownerTarget, ownerTargets, configuredOwnerTarget, ensureContact };
|
|
326
|
+
module.exports = { speakerFor, currentSpeaker, guardActive, isCurrentSpeakerGuarded, ownerTarget, ownerTargets, configuredOwnerTarget, ensureContact, resolveHandleSendTarget };
|
package/core/router.js
CHANGED
|
@@ -167,6 +167,16 @@ async function onMessage(envelope) {
|
|
|
167
167
|
// and the meet-greet lane all have a person to attach to.
|
|
168
168
|
try { require("./relationship").ensureContact(envelope); } catch (e) {}
|
|
169
169
|
|
|
170
|
+
// The person writing again drops their pick-up of a message a usage
|
|
171
|
+
// limit stopped (core/limit-pickup.js): the conversation moved on. Also
|
|
172
|
+
// when the guard then holds this message. Another bot's message does not.
|
|
173
|
+
if (!envelope.botHandoff) {
|
|
174
|
+
try {
|
|
175
|
+
const state = currentState();
|
|
176
|
+
require("./limit-pickup").cancel(state.userId, require("./state").currentLaneKey(state), "a new message");
|
|
177
|
+
} catch (e) {}
|
|
178
|
+
}
|
|
179
|
+
|
|
170
180
|
if (envelope.type === "voice") return handleVoice(envelope);
|
|
171
181
|
if (envelope.type === "audio") return handleAudio(envelope);
|
|
172
182
|
if (envelope.type === "photo") return handlePhoto(envelope);
|
|
@@ -431,8 +441,13 @@ async function handleDocument(envelope) {
|
|
|
431
441
|
attachments: [{ type: "document", mimeType: media.mimeType || null, fileName: media.fileName || null }],
|
|
432
442
|
});
|
|
433
443
|
stopPreTurnTyping = startPreTurnTyping(state);
|
|
444
|
+
// Saved before the guard runs: a held request resumes after the owner's
|
|
445
|
+
// approval from the guard's text alone (core/actions.js), so that text
|
|
446
|
+
// must say where the file is. The adapters keep the file inside FILES_DIR
|
|
447
|
+
// under a cleaned name (core/safe-filename.js), and that name is the one
|
|
448
|
+
// shown, never the sender's raw path.
|
|
434
449
|
const savePath = await envelope.adapter.downloadMedia(media);
|
|
435
|
-
const fileName =
|
|
450
|
+
const fileName = path.basename(savePath);
|
|
436
451
|
const mime = media.mimeType || "";
|
|
437
452
|
const caption = envelope.caption || "";
|
|
438
453
|
const isImage = mime.startsWith("image/");
|
package/core/run-context.js
CHANGED
|
@@ -213,6 +213,9 @@ function captureRunContext({
|
|
|
213
213
|
occurrenceId: opts.occurrenceId || null,
|
|
214
214
|
scheduledJobId: opts.scheduledJobId || null,
|
|
215
215
|
originRunId: opts.originRunId || null,
|
|
216
|
+
// Set on a turn that core/limit-pickup.js re-runs after a usage limit
|
|
217
|
+
// reset: { originalPrompt, firstHitAt, attempts }.
|
|
218
|
+
limitPickup: opts.limitPickup ? cloneSerializable(opts.limitPickup, "limit pickup") : null,
|
|
216
219
|
};
|
|
217
220
|
|
|
218
221
|
return deepFreeze(context);
|