@inetafrica/open-claudia 3.0.1 → 3.0.2
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/CHANGELOG.md +6 -0
- package/channels/telegram/adapter.js +16 -6
- package/channels/telegram/format.js +23 -1
- package/core/dream.js +32 -8
- package/core/runner.js +17 -2
- package/package.json +1 -1
- package/test-delivery-contract.js +28 -0
- package/test-provider-language.js +4 -3
- package/test-usage-accounting.js +24 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v3.0.2 — honest context numbers, dream summaries that arrive
|
|
4
|
+
|
|
5
|
+
- **The context warning counts the window again, not the bill.** The v3.0.0 provider rewrite computed the live context number from the provider's *cumulative* turn usage — every tool round-trip re-counted the cached prompt prefix, so a normal turn read as 347k+ "context" (crossing 1M on busy turns), spooked the usage alert, and could prematurely force-compact sessions whose real window was fine. The number now tracks the **peak single API call** in the turn (`peakContextTokens` over per-call usage events) — true window occupancy, the same semantics v2 reported (~160k on a heavy turn). Billing is untouched: ledger records keep the cumulative figure in `billedContextTokens`, and API-reported spend was never affected (v3 bills the same or less than v2 — the display was wrong, not the meter). The usage alert says "Context this turn" whenever the peak is available.
|
|
6
|
+
- **Nightly dream summaries reach the chat again.** Dreams ran every night at 04:00 — and for over a week every summary died with Telegram's `400: message is too long` (the report outgrew the 4,096-char hard cap and the adapter had no chunking), so consolidation happened invisibly. Two fixes: the Telegram adapter now splits any oversized message on paragraph → line → word boundaries and sends the chunks in order (reply anchor on the first, buttons on the last, per-chunk HTML→plain fallback preserved), and the dream's tool-health note caps long lists (unused tools, drifted docs) to a short digest in chat while the full lists still land in the on-disk dream log (`~/.open-claudia/dreams/<date>.md`).
|
|
7
|
+
- **Agent-space / OpenClaw:** no new deps, no new env, no schema change; Docker image builds identically. New hermetic assertions: peak-vs-cumulative derivation in `test-usage-accounting.js`, chunk-size and content-preservation in `test-delivery-contract.js`.
|
|
8
|
+
|
|
3
9
|
## v3.0.1 — streaming restored, /stop queue survival, boot resilience
|
|
4
10
|
|
|
5
11
|
- **Live streaming is back, and the final message stays clean.** The v3.0.0 provider rewrite dropped the in-chat streaming feel: no live progress while a run worked, and the settled reply concatenated every text block into one blob. Now an active run edits a throttled preview message in place with the agent's narration (and its thinking, when `/verbose` is on — new command + button toggle), and when the run settles only the **last** text segment is delivered as the final answer; earlier pre-tool text is preserved as narration instead of being glued onto the reply. Providers emit a normalized `thinking` event (Claude thinking blocks, Codex reasoning items) that feeds the preview but can never leak into settled output. On success the preview becomes the narration record (or is deleted when there was none); on failure or cancel the partial preview is kept so you can see how far the run got. Preview edits are channel-guarded, so a bubble minted on one channel is never edited from another.
|
|
@@ -10,7 +10,7 @@ const TelegramBot = require("node-telegram-bot-api");
|
|
|
10
10
|
const { TEMP_DIR, FILES_DIR, CONFIG_DIR } = require("../../core/config");
|
|
11
11
|
const { canonicalForChannel } = require("../../core/identity");
|
|
12
12
|
const { portableToInlineKeyboard } = require("../types");
|
|
13
|
-
const { telegramHtml, htmlToPlain } = require("./format");
|
|
13
|
+
const { telegramHtml, htmlToPlain, splitMessage } = require("./format");
|
|
14
14
|
|
|
15
15
|
class TelegramAdapter {
|
|
16
16
|
constructor({ id = "telegram", token, ownerChatId, chatIds }) {
|
|
@@ -316,13 +316,23 @@ class TelegramAdapter {
|
|
|
316
316
|
}
|
|
317
317
|
|
|
318
318
|
async send(channelId, text, opts = {}) {
|
|
319
|
-
const
|
|
320
|
-
let body = opts.parseMode === "HTML" ? telegramHtml(text) : text;
|
|
321
|
-
if (opts.parseMode) o.parse_mode = opts.parseMode;
|
|
319
|
+
const body = opts.parseMode === "HTML" ? telegramHtml(text) : text;
|
|
322
320
|
const kb = this._normalizeKeyboard(opts.keyboard);
|
|
323
|
-
|
|
324
|
-
|
|
321
|
+
const chunks = splitMessage(body);
|
|
322
|
+
let lastId = null;
|
|
323
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
324
|
+
const o = {};
|
|
325
|
+
if (opts.parseMode) o.parse_mode = opts.parseMode;
|
|
326
|
+
// Reply anchor goes on the first chunk; buttons belong on the last.
|
|
327
|
+
if (opts.replyTo && i === 0) o.reply_to_message_id = opts.replyTo;
|
|
328
|
+
if (kb && i === chunks.length - 1) o.reply_markup = kb;
|
|
329
|
+
const id = await this._sendChunk(channelId, chunks[i], o, opts);
|
|
330
|
+
if (id !== null) lastId = id;
|
|
331
|
+
}
|
|
332
|
+
return lastId;
|
|
333
|
+
}
|
|
325
334
|
|
|
335
|
+
async _sendChunk(channelId, body, o, opts = {}) {
|
|
326
336
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
327
337
|
try {
|
|
328
338
|
const msg = await this.bot.sendMessage(channelId, body, o);
|
|
@@ -139,4 +139,26 @@ function htmlToPlain(html) {
|
|
|
139
139
|
.replace(/&/g, "&");
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
-
|
|
142
|
+
// Telegram rejects messages over 4096 chars outright ("message is too long"),
|
|
143
|
+
// which silently killed long sends like the nightly dream summary. Split on
|
|
144
|
+
// paragraph > line > word boundaries, with margin for entity accounting.
|
|
145
|
+
function splitMessage(text, limit = 4000) {
|
|
146
|
+
const body = String(text ?? "");
|
|
147
|
+
if (body.length <= limit) return [body];
|
|
148
|
+
const chunks = [];
|
|
149
|
+
let rest = body;
|
|
150
|
+
while (rest.length > limit) {
|
|
151
|
+
const window = rest.slice(0, limit);
|
|
152
|
+
let cut = window.lastIndexOf("\n\n");
|
|
153
|
+
if (cut < limit * 0.5) cut = window.lastIndexOf("\n");
|
|
154
|
+
if (cut < limit * 0.5) cut = window.lastIndexOf(" ");
|
|
155
|
+
if (cut <= 0) cut = limit;
|
|
156
|
+
const chunk = rest.slice(0, cut).trimEnd();
|
|
157
|
+
if (chunk) chunks.push(chunk);
|
|
158
|
+
rest = rest.slice(cut).replace(/^\s+/, "");
|
|
159
|
+
}
|
|
160
|
+
if (rest) chunks.push(rest);
|
|
161
|
+
return chunks.length ? chunks : [""];
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
module.exports = { escapeHtml, stripMarkdown, telegramHtml, htmlToPlain, splitMessage };
|
package/core/dream.js
CHANGED
|
@@ -1246,17 +1246,29 @@ async function runDreamMutation({ trigger = "manual", provider = null, runUtilit
|
|
|
1246
1246
|
// Read-only toward the tools themselves — a tool is never auto-deleted (that's
|
|
1247
1247
|
// destructive); we just surface the breakage for the user to re-link or remove.
|
|
1248
1248
|
let toolNote = "";
|
|
1249
|
+
let toolNoteFull = "";
|
|
1249
1250
|
try {
|
|
1250
1251
|
const toolsLib = require("./tools");
|
|
1251
1252
|
const tg = require("./tool-graph").tend(toolsLib, { packsLib: packs });
|
|
1253
|
+
// Two variants: `bits` keeps full lists for the on-disk dream log;
|
|
1254
|
+
// `chatBits` caps the long ones so the chat digest stays readable.
|
|
1252
1255
|
const bits = [];
|
|
1256
|
+
const chatBits = [];
|
|
1257
|
+
const push = (full, chat = full) => { bits.push(full); chatBits.push(chat); };
|
|
1258
|
+
const capList = (items, sep = ", ", max = 5) => (items.length <= max
|
|
1259
|
+
? items.join(sep)
|
|
1260
|
+
: `${items.slice(0, max).join(sep)} …and ${items.length - max} more (full list in the dream log)`);
|
|
1253
1261
|
if (tg.edges || tg.decayed || tg.pruned) {
|
|
1254
|
-
|
|
1262
|
+
push(`🔧 Tool graph: ${tg.edges} edges / ${tg.nodes} nodes (decayed ${tg.decayed}, pruned ${tg.pruned}).`);
|
|
1255
1263
|
}
|
|
1256
1264
|
const allTools = toolsLib.listTools();
|
|
1257
1265
|
const dangling = allTools.filter((t) => t.pack && !packs.readPack(t.pack));
|
|
1258
1266
|
if (dangling.length) {
|
|
1259
|
-
|
|
1267
|
+
const names = dangling.map((t) => `${t.name}→${t.pack}`);
|
|
1268
|
+
push(
|
|
1269
|
+
`🔗 ${dangling.length} tool(s) link a missing skill pack: ${names.join(", ")} — re-link with --pack or remove.`,
|
|
1270
|
+
`🔗 ${dangling.length} tool(s) link a missing skill pack: ${capList(names)} — re-link with --pack or remove.`,
|
|
1271
|
+
);
|
|
1260
1272
|
}
|
|
1261
1273
|
// Zero-run tools: created but never run and gone cold. Telemetry runCount
|
|
1262
1274
|
// is authoritative; createdAt measures "how long since it was made" (a body
|
|
@@ -1271,7 +1283,11 @@ async function runDreamMutation({ trigger = "manual", provider = null, runUtilit
|
|
|
1271
1283
|
try { return fs.statSync(t.file).mtimeMs < cutoff; } catch (e) { return false; }
|
|
1272
1284
|
});
|
|
1273
1285
|
if (coldUnused.length) {
|
|
1274
|
-
|
|
1286
|
+
const names = coldUnused.map((t) => t.name);
|
|
1287
|
+
push(
|
|
1288
|
+
`🗃️ ${coldUnused.length} tool(s) created but never run in ${STALE_DAYS}+ days: ${names.join(", ")} — keep, fold into another, or remove.`,
|
|
1289
|
+
`🗃️ ${coldUnused.length} tool(s) created but never run in ${STALE_DAYS}+ days: ${capList(names)} — keep, fold into another, or remove.`,
|
|
1290
|
+
);
|
|
1275
1291
|
}
|
|
1276
1292
|
// Near-duplicate pairs: two tools whose name+description overlap strongly,
|
|
1277
1293
|
// a sign one should have been a subcommand of the other. Dedupe by sorted
|
|
@@ -1284,7 +1300,11 @@ async function runDreamMutation({ trigger = "manual", provider = null, runUtilit
|
|
|
1284
1300
|
for (const m of near) dupPairs.add([t.name, m.name].sort().join(" ↔ "));
|
|
1285
1301
|
}
|
|
1286
1302
|
if (dupPairs.size) {
|
|
1287
|
-
|
|
1303
|
+
const pairs = [...dupPairs];
|
|
1304
|
+
push(
|
|
1305
|
+
`👯 ${pairs.length} near-duplicate tool pair(s): ${pairs.join(", ")} — consider merging into one tool with subcommands.`,
|
|
1306
|
+
`👯 ${pairs.length} near-duplicate tool pair(s): ${capList(pairs)} — consider merging into one tool with subcommands.`,
|
|
1307
|
+
);
|
|
1288
1308
|
}
|
|
1289
1309
|
// Doc/code drift: source edited after TOOL.md, or verbs in real use that
|
|
1290
1310
|
// the docs never mention. Flag only — the fix is a tool note.
|
|
@@ -1292,7 +1312,10 @@ async function runDreamMutation({ trigger = "manual", provider = null, runUtilit
|
|
|
1292
1312
|
.map((t) => { const d = toolsLib.docDrift(t); return d ? `${t.name} (${d})` : null; })
|
|
1293
1313
|
.filter(Boolean);
|
|
1294
1314
|
if (drifted.length) {
|
|
1295
|
-
|
|
1315
|
+
push(
|
|
1316
|
+
`📝 ${drifted.length} tool(s) with doc/code drift: ${drifted.join("; ")} — refresh the docs with tool note.`,
|
|
1317
|
+
`📝 ${drifted.length} tool(s) with doc/code drift: ${capList(drifted, "; ")} — refresh the docs with tool note.`,
|
|
1318
|
+
);
|
|
1296
1319
|
}
|
|
1297
1320
|
// 5c usage KPI: what fraction of operational actions went through the
|
|
1298
1321
|
// authorised channel, computed from telemetry stamps (state.json run
|
|
@@ -1319,10 +1342,11 @@ async function runDreamMutation({ trigger = "manual", provider = null, runUtilit
|
|
|
1319
1342
|
}
|
|
1320
1343
|
if (g.denies.length) kpiBits.push(`${g.denies.length} denied by the gate`);
|
|
1321
1344
|
if (r.failures) kpiBits.push(`${r.failures} failed run(s) at use: ${r.failedTools.join(", ")}`);
|
|
1322
|
-
|
|
1345
|
+
push(`📈 Tool usage since last dream: ${kpiBits.join(" · ")}.`);
|
|
1323
1346
|
}
|
|
1324
1347
|
} catch (e) { /* KPI is best-effort */ }
|
|
1325
|
-
|
|
1348
|
+
toolNoteFull = bits.join("\n");
|
|
1349
|
+
toolNote = chatBits.join("\n");
|
|
1326
1350
|
} catch (e) { /* tool graph is best-effort (old node) */ }
|
|
1327
1351
|
|
|
1328
1352
|
const staleNote = staleTaskReport();
|
|
@@ -1396,7 +1420,7 @@ async function runDreamMutation({ trigger = "manual", provider = null, runUtilit
|
|
|
1396
1420
|
trigger,
|
|
1397
1421
|
consolidation: { report: decision.report, lines: consolidationLines },
|
|
1398
1422
|
introspection: { report: introReport, lines: introApplied, proposed: introProposed },
|
|
1399
|
-
healthNote, graphNote, episodicNote, toolNote, staleNote, backupRoot,
|
|
1423
|
+
healthNote, graphNote, episodicNote, toolNote: toolNoteFull || toolNote, staleNote, backupRoot,
|
|
1400
1424
|
});
|
|
1401
1425
|
|
|
1402
1426
|
// Richer chat summary: consolidation + introspection narrative + what
|
package/core/runner.js
CHANGED
|
@@ -84,6 +84,19 @@ function usageParts(usage, backend) {
|
|
|
84
84
|
};
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
// The provider's final "turn" usage sums every API call's prefix, re-counting
|
|
88
|
+
// the cached prefix once per tool round-trip. True window occupancy is the
|
|
89
|
+
// largest single call, so track the peak across per-call usage events.
|
|
90
|
+
function peakContextTokens(usageEvents, backend) {
|
|
91
|
+
let peak = 0;
|
|
92
|
+
for (const event of usageEvents || []) {
|
|
93
|
+
if (event?.scope !== "provider_call" || !event.usage) continue;
|
|
94
|
+
const context = usageParts(event.usage, backend).context;
|
|
95
|
+
if (context > peak) peak = context;
|
|
96
|
+
}
|
|
97
|
+
return peak > 0 ? peak : null;
|
|
98
|
+
}
|
|
99
|
+
|
|
87
100
|
function clampUsageDelta(current, previous) {
|
|
88
101
|
if (!previous) return current;
|
|
89
102
|
return {
|
|
@@ -192,7 +205,7 @@ function logTurnUsage(state, usage, costUsd, announce, opts = {}) {
|
|
|
192
205
|
if (announce && usageOwnerOnly) {
|
|
193
206
|
const baseline = result.trend?.baselineAvgContextTokens || 0;
|
|
194
207
|
const rate = result.trend?.latestRate || 0;
|
|
195
|
-
const contextLabel = usageScope === "turn_delta" ? "Context this turn" : "Session context total";
|
|
208
|
+
const contextLabel = hasPeak || usageScope === "turn_delta" ? "Context this turn" : "Session context total";
|
|
196
209
|
announce([
|
|
197
210
|
"Token usage alert:",
|
|
198
211
|
`${contextLabel}: ${fmtTokens(record.contextTokens)}`,
|
|
@@ -1420,7 +1433,8 @@ async function persistForegroundUsage(result, runContext, state = currentState()
|
|
|
1420
1433
|
let usage = result.usage;
|
|
1421
1434
|
let usageScope = result.usageScope || "turn_delta";
|
|
1422
1435
|
let rawUsage = usage;
|
|
1423
|
-
let liveContextTokens =
|
|
1436
|
+
let liveContextTokens = peakContextTokens(result.usageEvents, runContext.provider)
|
|
1437
|
+
?? usageParts(usage, runContext.provider).context;
|
|
1424
1438
|
const usageSessionId = result.sessionId || runContext.sessionId;
|
|
1425
1439
|
const usageProject = runContext.project?.name || null;
|
|
1426
1440
|
if (runContext.provider === "codex") {
|
|
@@ -2041,6 +2055,7 @@ module.exports = {
|
|
|
2041
2055
|
shouldAutoCompact,
|
|
2042
2056
|
effectiveCompactThreshold,
|
|
2043
2057
|
usageParts,
|
|
2058
|
+
peakContextTokens,
|
|
2044
2059
|
clampUsageDelta,
|
|
2045
2060
|
normalizeCodexUsage,
|
|
2046
2061
|
applyUsageToState,
|
package/package.json
CHANGED
|
@@ -84,5 +84,33 @@ const inChat = (adapter, fn) => runInChat({ adapter, channelId: "chan-1", transp
|
|
|
84
84
|
assert.deepStrictEqual(io.normalizeSendResult(null), { ok: false, messageId: null, editable: false });
|
|
85
85
|
assert.deepStrictEqual(io.normalizeSendResult(99), { ok: true, messageId: "99", editable: true });
|
|
86
86
|
|
|
87
|
+
// 7. Telegram splitMessage: messages over the 4096 hard cap must be chunked
|
|
88
|
+
// instead of dying with "message is too long" (the silent dream-summary
|
|
89
|
+
// failure). Chunks stay under the margin and preserve all content.
|
|
90
|
+
{
|
|
91
|
+
const { splitMessage } = require("./channels/telegram/format");
|
|
92
|
+
|
|
93
|
+
assert.deepStrictEqual(splitMessage("short message"), ["short message"], "under limit → single chunk untouched");
|
|
94
|
+
assert.deepStrictEqual(splitMessage(""), [""], "empty stays a single empty chunk");
|
|
95
|
+
|
|
96
|
+
const paragraphs = [];
|
|
97
|
+
for (let i = 0; i < 40; i++) paragraphs.push(`Paragraph ${i} ${"x".repeat(180)}`);
|
|
98
|
+
const longText = paragraphs.join("\n\n");
|
|
99
|
+
const chunks = splitMessage(longText);
|
|
100
|
+
assert.ok(chunks.length > 1, "long multi-paragraph text is chunked");
|
|
101
|
+
for (const c of chunks) assert.ok(c.length <= 4000, "every chunk fits Telegram's limit with margin");
|
|
102
|
+
assert.strictEqual(
|
|
103
|
+
chunks.join("\n\n").replace(/\s+/g, " "),
|
|
104
|
+
longText.replace(/\s+/g, " "),
|
|
105
|
+
"no content lost across paragraph-boundary splits",
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
const unbroken = "y".repeat(9500);
|
|
109
|
+
const hardChunks = splitMessage(unbroken);
|
|
110
|
+
assert.ok(hardChunks.length >= 3, "giant unbroken string hard-splits");
|
|
111
|
+
for (const c of hardChunks) assert.ok(c.length <= 4000, "hard-split chunks also fit");
|
|
112
|
+
assert.strictEqual(hardChunks.join(""), unbroken, "hard split loses nothing");
|
|
113
|
+
}
|
|
114
|
+
|
|
87
115
|
console.log("delivery-contract OK");
|
|
88
116
|
})().catch((e) => { console.error(e); process.exit(1); });
|
|
@@ -32,9 +32,10 @@ assert.doesNotMatch(setup, /Your Claude Code bot is connected|Description=Claude
|
|
|
32
32
|
|
|
33
33
|
const pkg = JSON.parse(read("package.json"));
|
|
34
34
|
assert.match(pkg.description, /provider-agnostic coding-agent harness/i);
|
|
35
|
-
// 3.0.
|
|
36
|
-
//
|
|
37
|
-
|
|
35
|
+
// 3.0.2 approved by Sumeet 2026-07-12 ("Yeh do it and push it out" — peak
|
|
36
|
+
// context metric restored, Telegram long-message chunking so dream summaries
|
|
37
|
+
// deliver; CHANGELOG documents it as v3.0.2).
|
|
38
|
+
assert.strictEqual(pkg.version, "3.0.2", "release version must remain unchanged without explicit approval");
|
|
38
39
|
for (const keyword of ["claude", "codex", "coding-agent", "provider-agnostic"]) {
|
|
39
40
|
assert.ok(pkg.keywords.includes(keyword), `package keyword missing: ${keyword}`);
|
|
40
41
|
}
|
package/test-usage-accounting.js
CHANGED
|
@@ -9,6 +9,7 @@ const {
|
|
|
9
9
|
normalizeCodexUsage,
|
|
10
10
|
applyUsageToState,
|
|
11
11
|
shouldAutoCompact,
|
|
12
|
+
peakContextTokens,
|
|
12
13
|
} = require("./core/runner");
|
|
13
14
|
const stateApi = require("./core/state");
|
|
14
15
|
const { normalizeUsageRecord, loadUsageHistory, USAGE_HISTORY_FILE } = require("./core/usage-log");
|
|
@@ -152,5 +153,28 @@ assert.strictEqual(shouldAutoCompact(state({
|
|
|
152
153
|
sessionUsage: { liveContextTokens: 250, lastInputTokens: 250, lastCompactedAt: Date.now() },
|
|
153
154
|
})), true);
|
|
154
155
|
|
|
156
|
+
// peakContextTokens: the live context number must be the LARGEST single API
|
|
157
|
+
// call, not the turn-cumulative sum (the v3.0.0 347k-vs-160k regression).
|
|
158
|
+
assert.strictEqual(peakContextTokens([
|
|
159
|
+
{ type: "usage", scope: "provider_call", usage: { input_tokens: 1000, cache_read_input_tokens: 150000, cache_creation_input_tokens: 2000 } },
|
|
160
|
+
{ type: "usage", scope: "provider_call", usage: { input_tokens: 500, cache_read_input_tokens: 158000, cache_creation_input_tokens: 1500 } },
|
|
161
|
+
{ type: "usage", scope: "provider_call", usage: { input_tokens: 200, cache_read_input_tokens: 120000 } },
|
|
162
|
+
], "claude"), 160000, "peak is the max single provider_call context");
|
|
163
|
+
assert.strictEqual(peakContextTokens([
|
|
164
|
+
{ type: "usage", scope: "provider_call", usage: { input_tokens: 100, cache_read_input_tokens: 50 } },
|
|
165
|
+
{ type: "usage", scope: "turn", usage: { input_tokens: 999999, cache_read_input_tokens: 999999 } },
|
|
166
|
+
], "claude"), 150, "turn-scope cumulative usage never wins over per-call peak");
|
|
167
|
+
assert.strictEqual(peakContextTokens([], "claude"), null, "no events → null so callers fall back");
|
|
168
|
+
assert.strictEqual(peakContextTokens([
|
|
169
|
+
{ type: "usage", scope: "turn", usage: { input_tokens: 10 } },
|
|
170
|
+
], "claude"), null, "only turn-scope events → null");
|
|
171
|
+
assert.strictEqual(peakContextTokens([
|
|
172
|
+
{ type: "usage", scope: "provider_call", usage: { output_tokens: 40 } },
|
|
173
|
+
], "claude"), null, "zero-context calls → null");
|
|
174
|
+
assert.strictEqual(peakContextTokens(null, "claude"), null, "missing events list tolerated");
|
|
175
|
+
assert.strictEqual(peakContextTokens([
|
|
176
|
+
{ type: "usage", scope: "provider_call", usage: { input_tokens: 70, cached_input_tokens: 30, output_tokens: 5 } },
|
|
177
|
+
], "codex"), 70, "codex context counts input only, matching usageParts");
|
|
178
|
+
|
|
155
179
|
fs.rmSync(configDir, { recursive: true, force: true });
|
|
156
180
|
console.log("usage accounting OK");
|