claude-doom-statusbar 0.4.0 → 0.5.0

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/statusline.js +94 -45
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-doom-statusbar",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "DOOM-inspired status bar for the Claude Code CLI — a mugshot that tracks session health, plus usage, model, project, system, and a live subagent list.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/statusline.js CHANGED
@@ -10,7 +10,7 @@
10
10
  // Config: $DOOMBAR_PRESET (default presets/default.toml) State: $MUGSHOT_STATE
11
11
 
12
12
  import {
13
- readFileSync, writeFileSync, openSync, fstatSync, readSync, closeSync, statfsSync,
13
+ readFileSync, writeFileSync, openSync, fstatSync, readSync, closeSync, statfsSync, statSync,
14
14
  } from "node:fs";
15
15
  import os from "node:os";
16
16
  import path from "node:path";
@@ -232,10 +232,10 @@ function sysValues(cwd) {
232
232
  return v;
233
233
  }
234
234
 
235
- // Token-savings rows read from the small JSON files context-optimization tools already
236
- // persist. No plugin patching, no binary spawn just a cheap read each refresh. Paths are
237
- // env-overridable (DOOMBAR_*) so tests can point at fixtures, mirroring statePath/MUGSHOT_STATE.
238
- const leanCtxPath = () => process.env.DOOMBAR_LEANCTX || path.join(os.homedir(), ".lean-ctx", "mcp-live.json");
235
+ // --- Token-savings rows -----------------------------------------------------
236
+ // Read from what context-optimization tools already persist on disk. No plugin
237
+ // patching, no binary spawn. Paths are env-overridable (DOOMBAR_*) for tests.
238
+ const eventsPath = () => process.env.DOOMBAR_EVENTS || path.join(os.homedir(), ".lean-ctx", "events.jsonl");
239
239
  const llmlinguaPath = () => process.env.DOOMBAR_LLMLINGUA || path.join(os.homedir(), ".llmlingua-stats.json");
240
240
 
241
241
  // Defensive read: missing file or malformed JSON -> null (the row simply never appears).
@@ -243,46 +243,95 @@ function readJson(p) {
243
243
  try { return JSON.parse(readFileSync(p, "utf8")); } catch { return null; }
244
244
  }
245
245
 
246
- // One entry per savings source; extract returns the display string or null (omit the row).
247
- // Adding a source later is one entry here plus one preset line not an adapter framework.
248
- const SAVINGS_SOURCES = [
249
- {
250
- key: "save.leanctx",
251
- path: leanCtxPath,
252
- extract: (d) => {
253
- if (!(d.tokens_saved > 0)) return null;
254
- // compression_rate is a 0-100 percentage (verified against historical data).
255
- // Round it a fractional value would render "63.45%" and shift the box width.
256
- return typeof d.compression_rate === "number"
257
- ? `${k(d.tokens_saved)} ${Math.round(d.compression_rate)}%`
258
- : k(d.tokens_saved);
259
- },
260
- },
261
- {
262
- key: "save.lingua",
263
- path: llmlinguaPath,
264
- extract: (d) => {
265
- // Prefer the nested session schema (smart-read). The flat lifetime-only shape
266
- // (llmlingua_logged.py: tokens_saved_total, no session) is absent for the session view.
267
- const s = d.session;
268
- if (!s || !(s.tokens_saved > 0)) return null;
269
- // Round/clamp the secondary figure so a many-decimal value can't shift box width.
270
- if (s.last_saved_pct != null) return `${k(s.tokens_saved)} ${Math.round(s.last_saved_pct)}%`;
271
- // No original-token count in the session block, so a percent isn't derivable; show the ratio.
272
- if (s.last_ratio != null) return `${k(s.tokens_saved)} ${Number(s.last_ratio).toFixed(1)}x`;
273
- return k(s.tokens_saved);
274
- },
275
- },
276
- ];
277
-
278
- export function statsValues() {
279
- const v = {};
280
- for (const src of SAVINGS_SOURCES) {
281
- const data = readJson(src.path());
282
- if (!data) continue;
283
- const out = src.extract(data);
284
- if (out) v[src.key] = out; // omit on null/zero -> render.js available() drops the row
246
+ // Windows FS is case-insensitive, POSIX is not only fold case on win32 to avoid
247
+ // false path matches (e.g. /home/User vs /home/user) on the platforms the npm package also runs on.
248
+ const normPath = (p) => {
249
+ const s = String(p).replace(/\\/g, "/");
250
+ return process.platform === "win32" ? s.toLowerCase() : s;
251
+ };
252
+
253
+ // "8.3k 63%" saved + a per-session compression rate derived from accumulated totals.
254
+ function fmtSaved(st) {
255
+ return st.original > 0 ? `${k(st.saved)} ${Math.round(100 * st.saved / st.original)}%` : k(st.saved);
256
+ }
257
+
258
+ const savingsStatePath = (sid) =>
259
+ process.env.DOOMBAR_SAVINGS_STATE || path.join(TMP, `savings_${sid}.json`);
260
+
261
+ // Per-session lean-ctx savings. lean-ctx's mcp-live.json is a single global file clobbered
262
+ // by every concurrent session, so it can't be per-session. events.jsonl is append-only and
263
+ // each ToolCall carries the file path it compressed — so we sum tokens_saved over NEW events
264
+ // (tracked by byte offset) whose path is under the current cwd, accumulated across refreshes
265
+ // in a per-session state file keyed by session_id. The accumulator follows cwd changes (it
266
+ // keeps adding wherever you currently work) and stays cheap — it reads only the bytes appended
267
+ // since the last refresh, never the whole log. Residual: two sessions concurrently in the SAME
268
+ // project share events and both count them — unsplittable from disk, accepted.
269
+ function leanCtxSavings(cwd, sid) {
270
+ if (!cwd) return null;
271
+ const cwdN = normPath(cwd).replace(/\/+$/, "") + "/"; // match on a path boundary, not a prefix
272
+ const sp = savingsStatePath(sid);
273
+ let st = { offset: null, saved: 0, original: 0 };
274
+ try { st = { ...st, ...JSON.parse(readFileSync(sp, "utf8")) }; } catch { /* fresh session */ }
275
+
276
+ let size = -1;
277
+ try { size = statSync(eventsPath()).size; } catch { /* no log */ }
278
+ if (size < 0) return st.saved > 0 ? fmtSaved(st) : null; // log gone -> keep prior total
279
+
280
+ if (st.offset === null) st.offset = size; // first sight of this session: count from now on
281
+ // Log shrank -> lean-ctx rotated it (old events go to archives/, the restarted log holds only
282
+ // NEW events). Keep the running total and re-read from 0: prior total + new events = correct.
283
+ // (An in-place truncate-and-rewrite retaining old content would double-count, but an append-only
284
+ // log doesn't do that.)
285
+ if (st.offset > size) st.offset = 0;
286
+
287
+ if (size > st.offset) {
288
+ let chunk = "";
289
+ try {
290
+ const fd = openSync(eventsPath(), "r");
291
+ const buf = Buffer.alloc(size - st.offset);
292
+ readSync(fd, buf, 0, buf.length, st.offset);
293
+ closeSync(fd);
294
+ chunk = buf.toString("utf8");
295
+ } catch { chunk = ""; }
296
+ const lastNl = chunk.lastIndexOf("\n"); // consume complete lines only; keep any partial tail
297
+ if (lastNl >= 0) {
298
+ for (const ln of chunk.slice(0, lastNl).split("\n")) {
299
+ if (!ln) continue;
300
+ let o; try { o = JSON.parse(ln); } catch { continue; }
301
+ const ev = o.kind;
302
+ if (!ev || ev.type !== "ToolCall" || !ev.path) continue;
303
+ if (!normPath(ev.path).startsWith(cwdN)) continue; // cwdN ends in "/" -> boundary-safe
304
+ st.saved += ev.tokens_saved || 0;
305
+ st.original += ev.tokens_original || 0;
306
+ }
307
+ st.offset += Buffer.byteLength(chunk.slice(0, lastNl + 1), "utf8");
308
+ }
285
309
  }
310
+ try { writeFileSync(sp, JSON.stringify(st)); } catch { /* ignore */ }
311
+ return st.saved > 0 ? fmtSaved(st) : null;
312
+ }
313
+
314
+ // Per-session llmlingua. smart-read keys its sessions map by CLAUDE_CODE_SESSION_ID — the same
315
+ // id the statusbar gets on stdin — so we read sessions[sid] (not a single global block).
316
+ // Prefer last_saved_pct; else show the ratio. Flat lifetime-only writers expose no session -> absent.
317
+ function linguaSavings(sid) {
318
+ const d = readJson(llmlinguaPath());
319
+ const s = d && d.sessions && d.sessions[sid];
320
+ if (!s || !(s.tokens_saved > 0)) return null;
321
+ if (s.last_saved_pct != null) return `${k(s.tokens_saved)} ${Math.round(s.last_saved_pct)}%`;
322
+ if (s.last_ratio != null) return `${k(s.tokens_saved)} ${Number(s.last_ratio).toFixed(1)}x`;
323
+ return k(s.tokens_saved);
324
+ }
325
+
326
+ export function statsValues(data, cwd) {
327
+ const v = {};
328
+ const rawSid = String((data && data.session_id) || "default");
329
+ const sid = rawSid.replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 48); // filesystem-safe state-file key
330
+ const lean = leanCtxSavings(cwd, sid);
331
+ if (lean) v["save.leanctx"] = lean;
332
+ // smart-read keys sessions[] by the RAW CLAUDE_CODE_SESSION_ID -> look up with the unsanitized id.
333
+ const ling = linguaSavings(rawSid);
334
+ if (ling) v["save.lingua"] = ling;
286
335
  return v;
287
336
  }
288
337
 
@@ -345,7 +394,7 @@ function main() {
345
394
  const now = Date.now() / 1000;
346
395
  const st = readState(data);
347
396
  const cwd = data.cwd || (data.workspace || {}).current_dir;
348
- const values = { ...buildValues(data), ...activityValues(st, now), ...sysValues(cwd), ...statsValues() };
397
+ const values = { ...buildValues(data), ...activityValues(st, now), ...sysValues(cwd), ...statsValues(data, cwd) };
349
398
  const [advModel, advTs] = advisorInfo(data.transcript_path || "");
350
399
  if (advModel) values["advisor.model"] = advModel;
351
400
  const god_until = godFlash(data, advTs, now);