@inetafrica/open-claudia 2.6.13 → 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 +6 -0
- package/CHANGELOG.md +6 -0
- package/README.md +4 -0
- package/bot.js +8 -0
- package/core/runner.js +39 -8
- package/core/system-prompt.js +48 -6
- package/core/usage-log.js +94 -1
- package/package.json +1 -1
- package/web.js +64 -16
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/bot.js
CHANGED
|
@@ -171,6 +171,14 @@ setInterval(checkForUpdates, 5 * 60 * 1000);
|
|
|
171
171
|
console.error("Loopback start failed:", e.message);
|
|
172
172
|
}
|
|
173
173
|
|
|
174
|
+
if (process.env.WEB_UI === "true") {
|
|
175
|
+
try {
|
|
176
|
+
require("./web.js").startWebServer();
|
|
177
|
+
} catch (e) {
|
|
178
|
+
console.error("Web UI start failed:", e.message);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
174
182
|
for (const adapter of adapters) {
|
|
175
183
|
try {
|
|
176
184
|
await adapter.start();
|
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 {
|
|
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
|
|
39
|
-
|
|
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
|
-
|
|
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;
|
package/core/system-prompt.js
CHANGED
|
@@ -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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
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([
|
|
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.
|
|
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 = [
|
|
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>';
|