@inetafrica/open-claudia 2.6.10 → 2.6.12

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/core/runner.js CHANGED
@@ -28,6 +28,35 @@ 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");
32
+
33
+ const PKG_VERSION = require("../package.json").version;
34
+
35
+ // One JSONL record per completed user turn (real turns only — compaction
36
+ // summary/seed and sub-agent capture runs are excluded). Powers the web
37
+ // dashboard's per-version tokenomics graphs.
38
+ function logTurnUsage(state, usage, costUsd) {
39
+ if (!usage) return;
40
+ const backend = state.settings?.backend || "claude";
41
+ const input = usage.input_tokens || 0;
42
+ const cacheRead = usage.cache_read_input_tokens || usage.cached_input_tokens || 0;
43
+ const cacheCreation = usage.cache_creation_input_tokens || 0;
44
+ const output = usage.output_tokens || 0;
45
+ appendUsageRecord({
46
+ ts: new Date().toISOString(),
47
+ version: PKG_VERSION,
48
+ backend,
49
+ model: state.settings?.model || (backend === "claude" ? DEFAULT_CLAUDE_MODEL : null),
50
+ userId: state.userId || null,
51
+ sessionId: state[getActiveSessionKey(state)] || null,
52
+ inputTokens: input,
53
+ outputTokens: output,
54
+ cacheReadTokens: cacheRead,
55
+ cacheCreationTokens: cacheCreation,
56
+ contextTokens: input + cacheRead + cacheCreation,
57
+ costUsd: typeof costUsd === "number" ? costUsd : 0,
58
+ });
59
+ }
31
60
 
32
61
  function telegramHtmlOpts(extra = {}) {
33
62
  const adapter = currentAdapter();
@@ -817,6 +846,7 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
817
846
  u.outputTokens += output;
818
847
  u.cacheReadTokens += cached;
819
848
  u.lastInputTokens = input + cached;
849
+ logTurnUsage(state, evt.usage, 0);
820
850
  saveState();
821
851
  }
822
852
  if (evt.type === "result" && evt.session_id) {
@@ -832,6 +862,10 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
832
862
  u.lastInputTokens = (evt.usage.input_tokens || 0) +
833
863
  (evt.usage.cache_read_input_tokens || 0) +
834
864
  (evt.usage.cache_creation_input_tokens || 0);
865
+ // Codex already logged this turn at turn.completed — don't double-count.
866
+ if (settings.backend !== "codex") {
867
+ logTurnUsage(state, evt.usage, typeof evt.total_cost_usd === "number" ? evt.total_cost_usd : 0);
868
+ }
835
869
  }
836
870
  if (typeof evt.total_cost_usd === "number") state.sessionUsage.costUsd += evt.total_cost_usd;
837
871
  saveState();
@@ -317,22 +317,6 @@ function formatPackForContext(pack, packsLib) {
317
317
  return clip(parts.join("\n\n"), PACK_INJECT_MAX_CHARS);
318
318
  }
319
319
 
320
- // Compact anchor re-injected on later turns of the same session: the full
321
- // pack was already shown once, but compaction can silently drop it from
322
- // the rolling context, leaving a recall hole. A short Stance+State+latest
323
- // reminder keeps the pack "present" without re-paying the full token cost.
324
- function formatPackCompact(pack) {
325
- const clip = (s, n) => (s.length > n ? s.slice(0, n) + "\n…[truncated]" : s);
326
- const parts = [`### Pack: ${pack.name} (${pack.dir}) — recalled (full version shown earlier this session; read PACK.md for detail)`];
327
- const stance = (pack.sections.Stance || "").trim();
328
- if (stance) parts.push(`#### Stance\n${stance}`);
329
- const state = (pack.sections.State || "").trim();
330
- if (state) parts.push(`#### State\n${state}`);
331
- const journal = (pack.sections.Journal || "").trim().split("\n").filter(Boolean).slice(-1).join("\n");
332
- if (journal) parts.push(`#### Journal (latest)\n${journal}`);
333
- return clip(parts.join("\n\n"), 1200);
334
- }
335
-
336
320
  function buildPackBlock(matches) {
337
321
  try {
338
322
  const packsLib = require("./packs");
@@ -349,14 +333,14 @@ function buildPackBlock(matches) {
349
333
  used.push(m.dir);
350
334
  const key = `${adapter?.id || "?"}:${channelId || "?"}:${m.dir}`;
351
335
  const stamp = `${sess}:${pack.updated}`;
352
- lastInjected.packs.push(pack.name || m.dir);
353
- if (packsInjectedFor.get(key) === stamp) {
354
- // Already injected the full pack this session-version; re-inject a
355
- // compact anchor so compaction can't silently drop it.
356
- blocks.push(formatPackCompact(pack));
357
- continue;
358
- }
336
+ // Inject a pack's full body once per (channel, session, version). A
337
+ // compaction mints a new session id, which changes the stamp and
338
+ // forces a fresh full re-injection on the next turn (same mechanism
339
+ // as the task tree), so the pack survives compaction without paying
340
+ // to re-stamp an anchor on every intervening turn.
341
+ if (packsInjectedFor.get(key) === stamp) continue;
359
342
  packsInjectedFor.set(key, stamp);
343
+ lastInjected.packs.push(pack.name || m.dir);
360
344
  blocks.push(formatPackForContext(pack, packsLib));
361
345
  }
362
346
  if (used.length > 0) setImmediate(() => { try { packsLib.touchUsed(used); } catch (e) {} });
@@ -0,0 +1,17 @@
1
+ // Durable per-turn token-usage log. Each completed user turn appends one
2
+ // JSONL record tagged with the open-claudia version + model, so the web
3
+ // dashboard can graph tokenomics over time and compare across versions.
4
+
5
+ const fs = require("fs");
6
+ const path = require("path");
7
+ const CONFIG_DIR = require("../config-dir");
8
+
9
+ const USAGE_HISTORY_FILE = path.join(CONFIG_DIR, "usage-history.jsonl");
10
+
11
+ function appendUsageRecord(record) {
12
+ try {
13
+ fs.appendFileSync(USAGE_HISTORY_FILE, JSON.stringify(record) + "\n");
14
+ } catch (e) {}
15
+ }
16
+
17
+ module.exports = { appendUsageRecord, USAGE_HISTORY_FILE };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inetafrica/open-claudia",
3
- "version": "2.6.10",
3
+ "version": "2.6.12",
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
@@ -12,6 +12,7 @@ const SOUL_FILE = path.join(CONFIG_DIR, "soul.md");
12
12
  const CRONS_FILE = path.join(CONFIG_DIR, "crons.json");
13
13
  const SESSIONS_FILE = path.join(CONFIG_DIR, "sessions.json");
14
14
  const WEB_PASSWORD_FILE = path.join(CONFIG_DIR, ".web-password");
15
+ const USAGE_HISTORY_FILE = path.join(CONFIG_DIR, "usage-history.jsonl");
15
16
  const PORT = parseInt(process.env.WEB_PORT || "8080", 10);
16
17
 
17
18
  // ── Password management ────────────────────────────────────────────
@@ -180,6 +181,48 @@ function isConfigured() {
180
181
  return fs.existsSync(ENV_FILE);
181
182
  }
182
183
 
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
+ // Group usage records by a key (version / model) and return per-group
197
+ // averages so the dashboard can compare tokenomics across versions.
198
+ function aggregateUsage(records, keyName) {
199
+ const map = new Map();
200
+ for (const r of records) {
201
+ const k = r[keyName] || r.backend || "unknown";
202
+ let m = map.get(k);
203
+ if (!m) { m = { key: k, turns: 0, input: 0, output: 0, ctx: 0, cost: 0, first: r.ts, last: r.ts }; map.set(k, m); }
204
+ m.turns += 1;
205
+ m.input += r.inputTokens || 0;
206
+ m.output += r.outputTokens || 0;
207
+ m.ctx += r.contextTokens || 0;
208
+ m.cost += r.costUsd || 0;
209
+ if (r.ts && r.ts < m.first) m.first = r.ts;
210
+ if (r.ts && r.ts > m.last) m.last = r.ts;
211
+ }
212
+ return [...map.values()]
213
+ .sort((a, b) => (a.first < b.first ? -1 : a.first > b.first ? 1 : 0))
214
+ .map((m) => ({
215
+ [keyName]: m.key,
216
+ turns: m.turns,
217
+ avgContextTokens: m.turns ? m.ctx / m.turns : 0,
218
+ avgInputTokens: m.turns ? m.input / m.turns : 0,
219
+ avgOutputTokens: m.turns ? m.output / m.turns : 0,
220
+ totalCostUsd: m.cost,
221
+ firstTs: m.first,
222
+ lastTs: m.last,
223
+ }));
224
+ }
225
+
183
226
  // ── Telegram helpers ───────────────────────────────────────────────
184
227
 
185
228
  function telegramGet(token, method) {
@@ -387,6 +430,26 @@ async function handleAPI(req, res, body) {
387
430
  return res.end(JSON.stringify({ content: loadLogs() }));
388
431
  }
389
432
 
433
+ // Token usage history (for the Usage dashboard)
434
+ if (url === "/api/usage") {
435
+ const records = loadUsageHistory();
436
+ const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, "package.json"), "utf-8"));
437
+ const series = records.slice(-500).map((r) => ({
438
+ ts: r.ts,
439
+ version: r.version || "?",
440
+ contextTokens: r.contextTokens || 0,
441
+ outputTokens: r.outputTokens || 0,
442
+ }));
443
+ res.writeHead(200, { "Content-Type": "application/json" });
444
+ return res.end(JSON.stringify({
445
+ currentVersion: pkg.version,
446
+ totalRecords: records.length,
447
+ byVersion: aggregateUsage(records, "version"),
448
+ byModel: aggregateUsage(records, "model"),
449
+ series,
450
+ }));
451
+ }
452
+
390
453
  // Change password
391
454
  if (url === "/api/password" && req.method === "POST") {
392
455
  const { current, newPassword } = JSON.parse(body);
@@ -457,7 +520,12 @@ function getHTML() {
457
520
  .msg.ok { background: #052e16; color: #22c55e; }
458
521
  .msg.err { background: #2a0a0a; color: #dc2626; }
459
522
  .hidden { display: none; }
523
+ table.usage-table { width: 100%; border-collapse: collapse; font-size: 13px; }
524
+ table.usage-table th, table.usage-table td { text-align: left; padding: 8px 6px; border-bottom: 1px solid #222; white-space: nowrap; }
525
+ table.usage-table th { color: #888; font-weight: 500; }
526
+ table.usage-table td:first-child { color: #e0e0e0; }
460
527
  </style>
528
+ <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
461
529
  </head>
462
530
  <body>
463
531
  <div id="app"></div>
@@ -536,6 +604,7 @@ async function doForceChange() {
536
604
  function render() {
537
605
  const tabs = [
538
606
  { id: "dashboard", label: "Dashboard" },
607
+ { id: "usage", label: "Usage" },
539
608
  { id: "auth", label: "Users" },
540
609
  { id: "soul", label: "Soul" },
541
610
  { id: "crons", label: "Crons" },
@@ -576,6 +645,27 @@ async function loadTab() {
576
645
  <div class="list-item"><span>Claude</span><span>\${status.claudePath}</span></div>
577
646
  </div>\`;
578
647
  }
648
+ else if (currentTab === "usage") {
649
+ const u = await api("/api/usage");
650
+ if (!u) return;
651
+ if (!u.totalRecords) {
652
+ 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
+ return;
654
+ }
655
+ el.innerHTML =
656
+ '<div class="card">' +
657
+ '<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>' +
659
+ '<canvas id="chart-version" height="150"></canvas>' +
660
+ '</div>' +
661
+ '<div class="card">' +
662
+ '<h2>Trend — recent turns</h2>' +
663
+ '<canvas id="chart-trend" height="150"></canvas>' +
664
+ '</div>' +
665
+ '<div class="card"><h2>By version</h2>' + usageTableHTML(u.byVersion, "version") + '</div>' +
666
+ '<div class="card"><h2>By model</h2>' + usageTableHTML(u.byModel, "model") + '</div>';
667
+ renderUsageCharts(u);
668
+ }
579
669
  else if (currentTab === "auth") {
580
670
  const auth = await api("/api/auth");
581
671
  if (!auth) return;
@@ -680,6 +770,55 @@ async function loadTab() {
680
770
  }
681
771
  }
682
772
 
773
+ function fmtK(n) { n = Math.round(n || 0); return n >= 1000 ? (n / 1000).toFixed(1) + "k" : String(n); }
774
+
775
+ function usageTableHTML(rows, keyName) {
776
+ if (!rows || !rows.length) return '<p style="color:#888">No data.</p>';
777
+ const head = '<table class="usage-table"><thead><tr><th>' + keyName +
778
+ '</th><th>turns</th><th>avg ctx/turn</th><th>avg in</th><th>avg out</th><th>cost</th></tr></thead><tbody>';
779
+ const body = rows.map(function (r) {
780
+ const name = r[keyName] || "—";
781
+ return '<tr><td>' + name + '</td><td>' + r.turns + '</td><td>' + fmtK(r.avgContextTokens) +
782
+ '</td><td>' + fmtK(r.avgInputTokens) + '</td><td>' + fmtK(r.avgOutputTokens) +
783
+ '</td><td>$' + (r.totalCostUsd || 0).toFixed(3) + '</td></tr>';
784
+ }).join("");
785
+ return head + body + "</tbody></table>";
786
+ }
787
+
788
+ let _usageCharts = {};
789
+ function renderUsageCharts(u) {
790
+ if (typeof Chart === "undefined") return;
791
+ Object.values(_usageCharts).forEach(function (c) { try { c.destroy(); } catch (e) {} });
792
+ _usageCharts = {};
793
+ const grid = { color: "#222" };
794
+ const ticks = { color: "#888" };
795
+ const vc = document.getElementById("chart-version");
796
+ if (vc) {
797
+ _usageCharts.version = new Chart(vc, {
798
+ type: "bar",
799
+ data: {
800
+ labels: u.byVersion.map(function (v) { return v.version; }),
801
+ datasets: [{ label: "Avg context tokens / turn", data: u.byVersion.map(function (v) { return Math.round(v.avgContextTokens); }), backgroundColor: "#6366f1" }],
802
+ },
803
+ options: { plugins: { legend: { display: false } }, scales: { y: { ticks: ticks, grid: grid, beginAtZero: true }, x: { ticks: ticks, grid: grid } } },
804
+ });
805
+ }
806
+ const tc = document.getElementById("chart-trend");
807
+ if (tc) {
808
+ _usageCharts.trend = new Chart(tc, {
809
+ type: "line",
810
+ data: {
811
+ labels: u.series.map(function (s, i) { return i + 1; }),
812
+ datasets: [
813
+ { label: "Context tokens", data: u.series.map(function (s) { return s.contextTokens; }), borderColor: "#6366f1", backgroundColor: "transparent", pointRadius: 0, tension: 0.2 },
814
+ { label: "Output tokens", data: u.series.map(function (s) { return s.outputTokens; }), borderColor: "#22c55e", backgroundColor: "transparent", pointRadius: 0, tension: 0.2 },
815
+ ],
816
+ },
817
+ options: { plugins: { legend: { labels: { color: "#888" } } }, scales: { y: { ticks: ticks, grid: grid, beginAtZero: true }, x: { ticks: ticks, grid: grid } } },
818
+ });
819
+ }
820
+ }
821
+
683
822
  async function removeAuth(chatId) { await api("/api/auth/remove", { method: "POST", body: { chatId } }); loadTab(); }
684
823
  async function approveAuth(chatId) { await api("/api/auth/approve", { method: "POST", body: { chatId } }); loadTab(); }
685
824
  async function denyAuth(chatId) { await api("/api/auth/deny", { method: "POST", body: { chatId } }); loadTab(); }