@inetafrica/open-claudia 2.6.14 → 2.6.15

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 CHANGED
@@ -21,6 +21,12 @@ CLAUDE_MODEL=claude-fable-5
21
21
  CURSOR_PATH=
22
22
  CODEX_PATH=
23
23
  AUTO_COMPACT_TOKENS=280000
24
+ USAGE_ALERT_CONTEXT_TOKENS=120000
25
+ USAGE_ALERT_RATE_MULTIPLIER=1.75
26
+ USAGE_ALERT_BASELINE_TURNS=20
27
+ USAGE_ALERT_MIN_BASELINE_TURNS=6
28
+ USAGE_ALERT_COOLDOWN_MS=1800000
29
+ MEMORY_RECALL_MAX_CHARS=9000
24
30
  PROJECT_TRANSCRIPTS=true
25
31
  TRANSCRIPT_MAX_ENTRY_CHARS=12000
26
32
  TRANSCRIPTS_DIR=
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## v2.6.15
4
+ - **Tokenomics guardrails.** The Usage dashboard now shows the latest context-token count, recent baseline, current rate multiplier, and active alert policy above the existing per-version/token trend charts. Completed turns already logged to `~/.open-claudia/usage-history.jsonl`; the dashboard now uses that history to make regressions visible immediately after `/upgrade`.
5
+ - **Hard memory recall budget.** Auto-recalled packs/entities now share a total `MEMORY_RECALL_MAX_CHARS` budget (default `9000`) after relevance filtering. Matched memory beyond the budget is omitted with a short pointer to inspect packs/entities manually, so long-term memory remains available without silently bloating every turn.
6
+ - **Token usage alerts.** After each completed real turn, Open Claudia compares the new context-token count against an absolute ceiling (`USAGE_ALERT_CONTEXT_TOKENS`, default `120000`) and the recent baseline rate (`USAGE_ALERT_RATE_MULTIPLIER`, default `1.75x`). If either trips, the bot posts a concise chat alert with the current version/model, measured context, baseline, and reason. `USAGE_ALERT_COOLDOWN_MS` prevents repeated noise. Set either threshold to `off` to disable that side.
7
+ - **Dashboard-tunable thresholds.** The web Settings page can edit `MEMORY_RECALL_MAX_CHARS`, `USAGE_ALERT_CONTEXT_TOKENS`, `USAGE_ALERT_RATE_MULTIPLIER`, `USAGE_ALERT_BASELINE_TURNS`, `USAGE_ALERT_MIN_BASELINE_TURNS`, and `USAGE_ALERT_COOLDOWN_MS`; restart the bot after saving so the runner picks up the new policy.
8
+
3
9
  ## v2.6.10
4
10
  - **Sessions: wakeups join the live conversation instead of forking it.** A scheduled wakeup/cron captured the channel's `sessionId` at schedule time and resumed that *frozen* id when it fired — but every run writes its result id back to the shared `state.lastSessionId`, and a home-conversation compaction mints a brand-new id. So a pending wakeup carrying a pre-compaction id would yank the channel's live pointer onto a dead thread, and the user's next typed message landed on the stale session. Multiple concurrent wakeups froze different ids and forked the channel into parallel sessions. Fix (`core/scheduler.js`): a wakeup now resumes the channel's live `state.lastSessionId` (already ownership-guarded in the runner); the frozen `job.sessionId` is used only as a cold-start fallback when there is no live pointer. Wakeups scheduled after upgrade join the current context; already-pending wakeups still carry their old id until they fire.
5
11
 
package/README.md CHANGED
@@ -407,6 +407,10 @@ All stored in `~/.open-claudia/`:
407
407
  | `CLAUDE_CODE_OAUTH_TOKEN` | No | OAuth token for non-interactive Claude runs (set via `/use_oauth_token`) |
408
408
  | `CLAUDE_MODEL` | No | Default Claude model |
409
409
  | `AUTO_COMPACT_TOKENS` | No | Auto-compact threshold in tokens (also settable via `/compactwindow`) |
410
+ | `USAGE_ALERT_CONTEXT_TOKENS` | No | Alert when one completed turn's context tokens exceed this ceiling (default `120000`, `off` disables) |
411
+ | `USAGE_ALERT_RATE_MULTIPLIER` | No | Alert when the latest context-token rate exceeds the recent baseline by this multiple (default `1.75`, `off` disables) |
412
+ | `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 |
413
+ | `MEMORY_RECALL_MAX_CHARS` | No | Hard cap for auto-injected pack/entity memory per turn (default `9000`, `off` disables auto recall injection) |
410
414
  | `PROJECT_TRANSCRIPTS` | No | Enable redacted project transcripts (default `true`) |
411
415
  | `TRANSCRIPT_MAX_ENTRY_CHARS` | No | Max chars per transcript entry (default `12000`) |
412
416
  | `TRANSCRIPTS_DIR` / `PACKS_DIR` / `ENTITIES_DIR` | No | Override storage directories |
package/core/runner.js CHANGED
@@ -9,7 +9,7 @@ const { spawn, execFileSync } = require("child_process");
9
9
  const {
10
10
  CLAUDE_PATH, DEFAULT_CLAUDE_MODEL, resolvedCursorPath, resolvedCodexPath,
11
11
  AUTO_COMPACT_TOKENS, MIN_COMPACT_INTERVAL_MS, MAX_PROCESS_TIMEOUT, COMPACT_SUMMARY_TIMEOUT, botSubprocessEnv,
12
- CONFIG_DIR,
12
+ CONFIG_DIR, config,
13
13
  } = require("./config");
14
14
  const { currentState, saveState, recordSession, userOwnsClaudeSession } = require("./state");
15
15
  const { chatContext, currentChannelId, currentAdapter } = require("./context");
@@ -28,21 +28,31 @@ const skillsLib = require("./skills");
28
28
  const packsLib = require("./packs");
29
29
  const entitiesLib = require("./entities");
30
30
  const packReview = require("./pack-review");
31
- const { appendUsageRecord } = require("./usage-log");
31
+ const {
32
+ appendUsageRecord,
33
+ loadUsageHistory,
34
+ usageAlertPolicy,
35
+ evaluateUsageAlert,
36
+ } = require("./usage-log");
32
37
 
33
38
  const PKG_VERSION = require("../package.json").version;
34
39
 
35
40
  // One JSONL record per completed user turn (real turns only — compaction
36
41
  // summary/seed and sub-agent capture runs are excluded). Powers the web
37
42
  // dashboard's per-version tokenomics graphs.
38
- function logTurnUsage(state, usage, costUsd) {
39
- if (!usage) return;
43
+ function fmtTokens(n) {
44
+ n = Math.round(n || 0);
45
+ return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n);
46
+ }
47
+
48
+ function logTurnUsage(state, usage, costUsd, announce) {
49
+ if (!usage) return null;
40
50
  const backend = state.settings?.backend || "claude";
41
51
  const input = usage.input_tokens || 0;
42
52
  const cacheRead = usage.cache_read_input_tokens || usage.cached_input_tokens || 0;
43
53
  const cacheCreation = usage.cache_creation_input_tokens || 0;
44
54
  const output = usage.output_tokens || 0;
45
- appendUsageRecord({
55
+ const record = {
46
56
  ts: new Date().toISOString(),
47
57
  version: PKG_VERSION,
48
58
  backend,
@@ -55,7 +65,28 @@ function logTurnUsage(state, usage, costUsd) {
55
65
  cacheCreationTokens: cacheCreation,
56
66
  contextTokens: input + cacheRead + cacheCreation,
57
67
  costUsd: typeof costUsd === "number" ? costUsd : 0,
58
- });
68
+ };
69
+ const previousRecords = loadUsageHistory();
70
+ appendUsageRecord(record);
71
+
72
+ const policy = usageAlertPolicy(config);
73
+ const lastAlertAt = state.usageAlertLastAt || 0;
74
+ const result = evaluateUsageAlert(previousRecords, record, policy, lastAlertAt);
75
+ if (result.alert) {
76
+ state.usageAlertLastAt = Date.now();
77
+ if (announce) {
78
+ const baseline = result.trend?.baselineAvgContextTokens || 0;
79
+ const rate = result.trend?.latestRate || 0;
80
+ announce([
81
+ "Token usage alert:",
82
+ `Context this turn: ${fmtTokens(record.contextTokens)}`,
83
+ baseline ? `Recent baseline: ${fmtTokens(baseline)} (${rate.toFixed(2)}x)` : null,
84
+ `Version/model: v${record.version} / ${record.model || record.backend}`,
85
+ `Reason: ${result.reasons.join("; ")}`,
86
+ ].filter(Boolean).join("\n")).catch(() => {});
87
+ }
88
+ }
89
+ return record;
59
90
  }
60
91
 
61
92
  function telegramHtmlOpts(extra = {}) {
@@ -846,7 +877,7 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
846
877
  u.outputTokens += output;
847
878
  u.cacheReadTokens += cached;
848
879
  u.lastInputTokens = input + cached;
849
- logTurnUsage(state, evt.usage, 0);
880
+ logTurnUsage(state, evt.usage, 0, (text) => chatContext.run(store, () => send(text)));
850
881
  saveState();
851
882
  }
852
883
  if (evt.type === "result" && evt.session_id) {
@@ -864,7 +895,7 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
864
895
  (evt.usage.cache_creation_input_tokens || 0);
865
896
  // Codex already logged this turn at turn.completed — don't double-count.
866
897
  if (settings.backend !== "codex") {
867
- logTurnUsage(state, evt.usage, typeof evt.total_cost_usd === "number" ? evt.total_cost_usd : 0);
898
+ logTurnUsage(state, evt.usage, typeof evt.total_cost_usd === "number" ? evt.total_cost_usd : 0, (text) => chatContext.run(store, () => send(text)));
868
899
  }
869
900
  }
870
901
  if (typeof evt.total_cost_usd === "number") state.sessionUsage.costUsd += evt.total_cost_usd;
@@ -4,7 +4,7 @@
4
4
 
5
5
  const fs = require("fs");
6
6
  const path = require("path");
7
- const { SOUL_FILE, CRONS_FILE, VAULT_FILE, FILES_DIR, BOT_DIR, WHISPER_CLI, FFMPEG } = require("./config");
7
+ const { SOUL_FILE, CRONS_FILE, VAULT_FILE, FILES_DIR, BOT_DIR, WHISPER_CLI, FFMPEG, config } = require("./config");
8
8
  const CONFIG_DIR = require("../config-dir");
9
9
  const { currentState } = require("./state");
10
10
  const { currentAdapter, currentChannelId } = require("./context");
@@ -284,6 +284,38 @@ function buildDynamicContextBlock() {
284
284
  // it's in the conversation the model keeps it until compaction.
285
285
  const packsInjectedFor = new Map(); // `${adapterId}:${channelId}:${dir}` -> `${sessionId}:${updated}`
286
286
  const PACK_INJECT_MAX_CHARS = 4000;
287
+ const DEFAULT_MEMORY_RECALL_MAX_CHARS = 9000;
288
+
289
+ function intSetting(key, fallback) {
290
+ const raw = config[key] ?? process.env[key];
291
+ if (raw === undefined || raw === null || raw === "") return fallback;
292
+ if (/^(off|false|disabled)$/i.test(String(raw).trim())) return 0;
293
+ const n = parseInt(String(raw).replace(/_/g, ""), 10);
294
+ return Number.isFinite(n) ? n : fallback;
295
+ }
296
+
297
+ function memoryRecallBudget() {
298
+ return {
299
+ maxChars: intSetting("MEMORY_RECALL_MAX_CHARS", DEFAULT_MEMORY_RECALL_MAX_CHARS),
300
+ usedChars: 0,
301
+ omitted: 0,
302
+ };
303
+ }
304
+
305
+ function tryUseRecallBudget(budget, text) {
306
+ if (!budget) return true;
307
+ if (budget.maxChars <= 0) {
308
+ budget.omitted += 1;
309
+ return false;
310
+ }
311
+ const n = String(text || "").length;
312
+ if (budget.usedChars + n > budget.maxChars) {
313
+ budget.omitted += 1;
314
+ return false;
315
+ }
316
+ budget.usedChars += n;
317
+ return true;
318
+ }
287
319
 
288
320
  // What the last promptWithDynamicContext call freshly injected (not the
289
321
  // deduped repeats) — consumed by the runner to announce recalls in chat,
@@ -317,7 +349,7 @@ function formatPackForContext(pack, packsLib) {
317
349
  return clip(parts.join("\n\n"), PACK_INJECT_MAX_CHARS);
318
350
  }
319
351
 
320
- function buildPackBlock(matches) {
352
+ function buildPackBlock(matches, budget) {
321
353
  try {
322
354
  const packsLib = require("./packs");
323
355
  if (matches.length === 0) return "";
@@ -339,9 +371,11 @@ function buildPackBlock(matches) {
339
371
  // as the task tree), so the pack survives compaction without paying
340
372
  // to re-stamp an anchor on every intervening turn.
341
373
  if (packsInjectedFor.get(key) === stamp) continue;
374
+ const block = formatPackForContext(pack, packsLib);
375
+ if (!tryUseRecallBudget(budget, block)) continue;
342
376
  packsInjectedFor.set(key, stamp);
343
377
  lastInjected.packs.push(pack.name || m.dir);
344
- blocks.push(formatPackForContext(pack, packsLib));
378
+ blocks.push(block);
345
379
  }
346
380
  if (used.length > 0) setImmediate(() => { try { packsLib.touchUsed(used); } catch (e) {} });
347
381
  if (blocks.length === 0) return "";
@@ -368,7 +402,7 @@ function formatEntityForContext(ent) {
368
402
  return clip(parts.join("\n\n"), ENTITY_INJECT_MAX_CHARS);
369
403
  }
370
404
 
371
- function buildEntityBlock(matches) {
405
+ function buildEntityBlock(matches, budget) {
372
406
  try {
373
407
  const entitiesLib = require("./entities");
374
408
  if (matches.length === 0) return "";
@@ -385,9 +419,11 @@ function buildEntityBlock(matches) {
385
419
  const key = `${adapter?.id || "?"}:${channelId || "?"}:${m.slug}`;
386
420
  const stamp = `${sess}:${ent.updated}`;
387
421
  if (entitiesInjectedFor.get(key) === stamp) continue;
422
+ const block = formatEntityForContext(ent);
423
+ if (!tryUseRecallBudget(budget, block)) continue;
388
424
  entitiesInjectedFor.set(key, stamp);
389
425
  lastInjected.entities.push(ent.name || m.slug);
390
- blocks.push(formatEntityForContext(ent));
426
+ blocks.push(block);
391
427
  }
392
428
  if (seen.length > 0) setImmediate(() => { try { entitiesLib.touchSeen(seen); } catch (e) {} });
393
429
  if (blocks.length === 0) return "";
@@ -515,7 +551,13 @@ async function promptWithDynamicContext(prompt) {
515
551
  packMatches = packMatches.slice(0, 3);
516
552
  entityMatches = entityMatches.slice(0, 4);
517
553
  logRecall(userText, candPacks, candEntities, packMatches, entityMatches);
518
- return `${buildDynamicContextBlock()}${buildPackBlock(packMatches)}${buildEntityBlock(entityMatches)}\n\nCurrent user request:\n${prompt}`;
554
+ const budget = memoryRecallBudget();
555
+ const packBlock = buildPackBlock(packMatches, budget);
556
+ const entityBlock = buildEntityBlock(entityMatches, budget);
557
+ const budgetNote = budget.omitted > 0
558
+ ? `\n\n## Memory budget\n${budget.omitted} matched memory item${budget.omitted === 1 ? " was" : "s were"} omitted to keep this turn under the recall budget (${budget.maxChars} chars). Use \`open-claudia pack show <dir>\`, \`entity show <slug>\`, or transcript search if deeper context is needed.`
559
+ : "";
560
+ return `${buildDynamicContextBlock()}${packBlock}${entityBlock}${budgetNote}\n\nCurrent user request:\n${prompt}`;
519
561
  } catch (e) {
520
562
  return prompt;
521
563
  }
package/core/usage-log.js CHANGED
@@ -14,4 +14,97 @@ function appendUsageRecord(record) {
14
14
  } catch (e) {}
15
15
  }
16
16
 
17
- module.exports = { appendUsageRecord, USAGE_HISTORY_FILE };
17
+ function loadUsageHistory() {
18
+ let raw;
19
+ try { raw = fs.readFileSync(USAGE_HISTORY_FILE, "utf-8"); } catch (e) { return []; }
20
+ const out = [];
21
+ for (const line of raw.split("\n")) {
22
+ const t = line.trim();
23
+ if (!t) continue;
24
+ try { out.push(JSON.parse(t)); } catch (e) {}
25
+ }
26
+ return out;
27
+ }
28
+
29
+ function intEnv(env, key, fallback) {
30
+ const raw = env[key] ?? process.env[key];
31
+ if (raw === undefined || raw === null || raw === "") return fallback;
32
+ if (/^(off|false|disabled)$/i.test(String(raw).trim())) return 0;
33
+ const n = parseInt(String(raw).replace(/_/g, ""), 10);
34
+ return Number.isFinite(n) ? n : fallback;
35
+ }
36
+
37
+ function floatEnv(env, key, fallback) {
38
+ const raw = env[key] ?? process.env[key];
39
+ if (raw === undefined || raw === null || raw === "") return fallback;
40
+ if (/^(off|false|disabled)$/i.test(String(raw).trim())) return 0;
41
+ const n = parseFloat(String(raw));
42
+ return Number.isFinite(n) ? n : fallback;
43
+ }
44
+
45
+ function usageAlertPolicy(env = {}) {
46
+ const absoluteContextTokens = intEnv(env, "USAGE_ALERT_CONTEXT_TOKENS", 120000);
47
+ const rateMultiplier = floatEnv(env, "USAGE_ALERT_RATE_MULTIPLIER", 1.75);
48
+ return {
49
+ enabled: absoluteContextTokens > 0 || rateMultiplier > 0,
50
+ absoluteContextTokens,
51
+ rateMultiplier,
52
+ baselineTurns: intEnv(env, "USAGE_ALERT_BASELINE_TURNS", 20),
53
+ minBaselineTurns: intEnv(env, "USAGE_ALERT_MIN_BASELINE_TURNS", 6),
54
+ cooldownMs: intEnv(env, "USAGE_ALERT_COOLDOWN_MS", 30 * 60 * 1000),
55
+ };
56
+ }
57
+
58
+ function avg(nums) {
59
+ if (!nums.length) return 0;
60
+ return nums.reduce((a, b) => a + b, 0) / nums.length;
61
+ }
62
+
63
+ function usageTrend(records, policy = usageAlertPolicy()) {
64
+ const usable = records.filter((r) => Number.isFinite(r.contextTokens));
65
+ const latest = usable[usable.length - 1] || null;
66
+ const previous = usable.slice(0, -1);
67
+ const baselineRecords = previous.slice(-Math.max(1, policy.baselineTurns || 20));
68
+ const baselineAvgContextTokens = avg(baselineRecords.map((r) => r.contextTokens || 0));
69
+ const latestRate = baselineAvgContextTokens && latest
70
+ ? latest.contextTokens / baselineAvgContextTokens
71
+ : 0;
72
+ return {
73
+ latest,
74
+ baselineTurns: baselineRecords.length,
75
+ baselineAvgContextTokens,
76
+ latestRate,
77
+ };
78
+ }
79
+
80
+ function evaluateUsageAlert(previousRecords, record, policy = usageAlertPolicy(), lastAlertAt = 0) {
81
+ if (!policy.enabled || !record) return { alert: false };
82
+ const now = Date.now();
83
+ if (lastAlertAt && policy.cooldownMs > 0 && now - lastAlertAt < policy.cooldownMs) {
84
+ return { alert: false, suppressed: true };
85
+ }
86
+ const contextTokens = record.contextTokens || 0;
87
+ const combined = [...previousRecords, record];
88
+ const trend = usageTrend(combined, policy);
89
+ const reasons = [];
90
+ if (policy.absoluteContextTokens > 0 && contextTokens >= policy.absoluteContextTokens) {
91
+ reasons.push(`context ${contextTokens} >= ${policy.absoluteContextTokens}`);
92
+ }
93
+ if (
94
+ policy.rateMultiplier > 0 &&
95
+ trend.baselineTurns >= policy.minBaselineTurns &&
96
+ trend.latestRate >= policy.rateMultiplier
97
+ ) {
98
+ reasons.push(`rate ${trend.latestRate.toFixed(2)}x recent baseline`);
99
+ }
100
+ return { alert: reasons.length > 0, reasons, trend };
101
+ }
102
+
103
+ module.exports = {
104
+ appendUsageRecord,
105
+ loadUsageHistory,
106
+ usageAlertPolicy,
107
+ usageTrend,
108
+ evaluateUsageAlert,
109
+ USAGE_HISTORY_FILE,
110
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inetafrica/open-claudia",
3
- "version": "2.6.14",
3
+ "version": "2.6.15",
4
4
  "description": "Your always-on AI coding assistant — Claude Code, Cursor Agent, and OpenAI Codex via Telegram or Kazee Chat",
5
5
  "main": "bot.js",
6
6
  "bin": {
package/web.js CHANGED
@@ -5,6 +5,11 @@ const crypto = require("crypto");
5
5
  const https = require("https");
6
6
  const CONFIG_DIR = require("./config-dir");
7
7
  const Vault = require("./vault");
8
+ const {
9
+ loadUsageHistory,
10
+ usageAlertPolicy,
11
+ usageTrend,
12
+ } = require("./core/usage-log");
8
13
 
9
14
  const ENV_FILE = path.join(CONFIG_DIR, ".env");
10
15
  const AUTH_FILE = path.join(CONFIG_DIR, "auth.json");
@@ -12,7 +17,6 @@ const SOUL_FILE = path.join(CONFIG_DIR, "soul.md");
12
17
  const CRONS_FILE = path.join(CONFIG_DIR, "crons.json");
13
18
  const SESSIONS_FILE = path.join(CONFIG_DIR, "sessions.json");
14
19
  const WEB_PASSWORD_FILE = path.join(CONFIG_DIR, ".web-password");
15
- const USAGE_HISTORY_FILE = path.join(CONFIG_DIR, "usage-history.jsonl");
16
20
  const PORT = parseInt(process.env.WEB_PORT || "8080", 10);
17
21
 
18
22
  // ── Password management ────────────────────────────────────────────
@@ -181,18 +185,6 @@ function isConfigured() {
181
185
  return fs.existsSync(ENV_FILE);
182
186
  }
183
187
 
184
- function loadUsageHistory() {
185
- let raw;
186
- try { raw = fs.readFileSync(USAGE_HISTORY_FILE, "utf-8"); } catch (e) { return []; }
187
- const out = [];
188
- for (const line of raw.split("\n")) {
189
- const t = line.trim();
190
- if (!t) continue;
191
- try { out.push(JSON.parse(t)); } catch (e) {}
192
- }
193
- return out;
194
- }
195
-
196
188
  // Group usage records by a key (version / model) and return per-group
197
189
  // averages so the dashboard can compare tokenomics across versions.
198
190
  function aggregateUsage(records, keyName) {
@@ -340,7 +332,13 @@ async function handleAPI(req, res, body) {
340
332
 
341
333
  // Update config (whitelist safe keys only)
342
334
  if (url === "/api/config" && req.method === "POST") {
343
- const SAFE_KEYS = new Set(["WORKSPACE", "CLAUDE_PATH", "WHISPER_CLI", "FFMPEG", "WHISPER_MODEL"]);
335
+ const SAFE_KEYS = new Set([
336
+ "WORKSPACE", "CLAUDE_PATH", "WHISPER_CLI", "FFMPEG", "WHISPER_MODEL",
337
+ "MEMORY_RECALL_MAX_CHARS",
338
+ "USAGE_ALERT_CONTEXT_TOKENS", "USAGE_ALERT_RATE_MULTIPLIER",
339
+ "USAGE_ALERT_BASELINE_TURNS", "USAGE_ALERT_MIN_BASELINE_TURNS",
340
+ "USAGE_ALERT_COOLDOWN_MS",
341
+ ]);
344
342
  const updates = JSON.parse(body);
345
343
  const env = loadEnv();
346
344
  for (const [key, value] of Object.entries(updates)) {
@@ -434,11 +432,17 @@ async function handleAPI(req, res, body) {
434
432
  if (url === "/api/usage") {
435
433
  const records = loadUsageHistory();
436
434
  const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, "package.json"), "utf-8"));
435
+ const env = loadEnv();
436
+ const alertPolicy = usageAlertPolicy(env);
437
+ const trend = usageTrend(records, alertPolicy);
437
438
  const series = records.slice(-500).map((r) => ({
438
439
  ts: r.ts,
439
440
  version: r.version || "?",
440
441
  contextTokens: r.contextTokens || 0,
441
442
  outputTokens: r.outputTokens || 0,
443
+ inputTokens: r.inputTokens || 0,
444
+ cacheReadTokens: r.cacheReadTokens || 0,
445
+ cacheCreationTokens: r.cacheCreationTokens || 0,
442
446
  }));
443
447
  res.writeHead(200, { "Content-Type": "application/json" });
444
448
  return res.end(JSON.stringify({
@@ -446,6 +450,8 @@ async function handleAPI(req, res, body) {
446
450
  totalRecords: records.length,
447
451
  byVersion: aggregateUsage(records, "version"),
448
452
  byModel: aggregateUsage(records, "model"),
453
+ alertPolicy,
454
+ trend,
449
455
  series,
450
456
  }));
451
457
  }
@@ -520,6 +526,12 @@ function getHTML() {
520
526
  .msg.ok { background: #052e16; color: #22c55e; }
521
527
  .msg.err { background: #2a0a0a; color: #dc2626; }
522
528
  .hidden { display: none; }
529
+ .metric-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 12px; margin-top: 12px; }
530
+ .metric { background: #0f0f0f; border: 1px solid #262626; border-radius: 8px; padding: 12px; }
531
+ .metric .label { color: #888; font-size: 12px; margin-bottom: 6px; }
532
+ .metric .value { color: #fff; font-size: 22px; font-weight: 650; }
533
+ .metric.warn .value { color: #f97316; }
534
+ .policy { color:#aaa; font-size:13px; line-height:1.5; margin-top:12px; }
523
535
  table.usage-table { width: 100%; border-collapse: collapse; font-size: 13px; }
524
536
  table.usage-table th, table.usage-table td { text-align: left; padding: 8px 6px; border-bottom: 1px solid #222; white-space: nowrap; }
525
537
  table.usage-table th { color: #888; font-weight: 500; }
@@ -652,10 +664,24 @@ async function loadTab() {
652
664
  el.innerHTML = '<div class="card"><h2>Token Usage</h2><p style="color:#888">No usage recorded yet. Send a few messages and check back — each turn is logged with the running version.</p></div>';
653
665
  return;
654
666
  }
667
+ const latestCtx = u.trend && u.trend.latest ? u.trend.latest.contextTokens : 0;
668
+ const baselineCtx = u.trend ? u.trend.baselineAvgContextTokens : 0;
669
+ const latestRate = u.trend ? u.trend.latestRate : 0;
670
+ const rateWarn = u.alertPolicy && u.alertPolicy.rateMultiplier > 0 && latestRate >= u.alertPolicy.rateMultiplier;
655
671
  el.innerHTML =
672
+ '<div class="card">' +
673
+ '<h2>Token Rate</h2>' +
674
+ '<p style="color:#888;font-size:13px;">Current: v' + u.currentVersion + ' · ' + u.totalRecords + ' turns logged</p>' +
675
+ '<div class="metric-grid">' +
676
+ '<div class="metric"><div class="label">Latest context</div><div class="value">' + fmtK(latestCtx) + '</div></div>' +
677
+ '<div class="metric"><div class="label">Recent baseline</div><div class="value">' + fmtK(baselineCtx) + '</div></div>' +
678
+ '<div class="metric ' + (rateWarn ? 'warn' : '') + '"><div class="label">Current rate</div><div class="value">' + fmtRate(latestRate) + '</div></div>' +
679
+ '</div>' +
680
+ usagePolicyHTML(u.alertPolicy, u.trend) +
681
+ '</div>' +
656
682
  '<div class="card">' +
657
683
  '<h2>Avg context tokens / turn — by version</h2>' +
658
- '<p style="color:#888;font-size:13px;margin-bottom:12px;">Lower is better. Current: v' + u.currentVersion + ' · ' + u.totalRecords + ' turns logged</p>' +
684
+ '<p style="color:#888;font-size:13px;margin-bottom:12px;">Lower is better. Compare this after each /upgrade.</p>' +
659
685
  '<canvas id="chart-version" height="150"></canvas>' +
660
686
  '</div>' +
661
687
  '<div class="card">' +
@@ -763,7 +789,13 @@ async function loadTab() {
763
789
  </div>\`;
764
790
  const config = await api("/api/config");
765
791
  if (!config) return;
766
- const safe = ["WORKSPACE", "CLAUDE_PATH", "WHISPER_CLI", "FFMPEG", "WHISPER_MODEL"];
792
+ const safe = [
793
+ "WORKSPACE", "CLAUDE_PATH", "WHISPER_CLI", "FFMPEG", "WHISPER_MODEL",
794
+ "MEMORY_RECALL_MAX_CHARS",
795
+ "USAGE_ALERT_CONTEXT_TOKENS", "USAGE_ALERT_RATE_MULTIPLIER",
796
+ "USAGE_ALERT_BASELINE_TURNS", "USAGE_ALERT_MIN_BASELINE_TURNS",
797
+ "USAGE_ALERT_COOLDOWN_MS",
798
+ ];
767
799
  $("#config-fields").innerHTML = safe.map(k => \`
768
800
  <div class="form-group"><label>\${k}</label><input id="cfg-\${k}" value="\${config[k] || ""}"></div>
769
801
  \`).join("");
@@ -771,6 +803,22 @@ async function loadTab() {
771
803
  }
772
804
 
773
805
  function fmtK(n) { n = Math.round(n || 0); return n >= 1000 ? (n / 1000).toFixed(1) + "k" : String(n); }
806
+ function fmtRate(n) { return n ? n.toFixed(2) + "x" : "—"; }
807
+
808
+ function usagePolicyHTML(policy, trend) {
809
+ if (!policy || !policy.enabled) {
810
+ return '<div class="policy">Alerts disabled. Set USAGE_ALERT_CONTEXT_TOKENS or USAGE_ALERT_RATE_MULTIPLIER to enable them.</div>';
811
+ }
812
+ const parts = [];
813
+ if (policy.absoluteContextTokens > 0) parts.push('absolute ceiling ' + fmtK(policy.absoluteContextTokens));
814
+ if (policy.rateMultiplier > 0) {
815
+ parts.push('rate ceiling ' + policy.rateMultiplier + 'x after ' + policy.minBaselineTurns + ' baseline turns');
816
+ }
817
+ const baseline = trend && trend.baselineTurns ? trend.baselineTurns : 0;
818
+ return '<div class="policy">Alert policy: ' + parts.join(' · ') +
819
+ ' · cooldown ' + Math.round((policy.cooldownMs || 0) / 60000) + ' min' +
820
+ ' · baseline sample ' + baseline + '/' + policy.baselineTurns + ' turns</div>';
821
+ }
774
822
 
775
823
  function usageTableHTML(rows, keyName) {
776
824
  if (!rows || !rows.length) return '<p style="color:#888">No data.</p>';