oc-inspector 1.3.0 → 1.3.1

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 (3) hide show
  1. package/bin/cli.mjs +250 -45
  2. package/package.json +1 -1
  3. package/src/proxy.mjs +19 -0
package/bin/cli.mjs CHANGED
@@ -38,6 +38,17 @@ import { fileURLToPath } from "node:url";
38
38
  const __filename = fileURLToPath(import.meta.url);
39
39
  const args = process.argv.slice(2);
40
40
 
41
+ // ── ANSI escapes & column widths (must be before command routing) ──
42
+ const E = {
43
+ R: "\x1b[0m", B: "\x1b[1m", D: "\x1b[90m",
44
+ RED: "\x1b[31m", GRN: "\x1b[32m", YEL: "\x1b[33m",
45
+ CYN: "\x1b[36m", ORG: "\x1b[38;5;208m",
46
+ BG: "\x1b[48;5;236m",
47
+ CLR: "\x1b[2J\x1b[H",
48
+ HIDE: "\x1b[?25l", SHOW: "\x1b[?25h",
49
+ };
50
+ const COL = { name: 24, bar: 20, tokens: 8, reqs: 7, cost: 10 };
51
+
41
52
  /** Inspector state directory for PID/log files. */
42
53
  const INSPECTOR_DIR = join(homedir(), ".openclaw", ".inspector-runtime");
43
54
 
@@ -59,6 +70,7 @@ function parseArgs(argv) {
59
70
  open: false,
60
71
  config: undefined,
61
72
  json: false,
73
+ live: false,
62
74
  days: 7,
63
75
  lines: 50,
64
76
  help: false,
@@ -67,7 +79,7 @@ function parseArgs(argv) {
67
79
  "start", "stop", "run", "restart",
68
80
  "enable", "disable", "status",
69
81
  "stats", "providers", "history", "pricing", "config",
70
- "logs", "_serve",
82
+ "logs", "help", "_serve",
71
83
  ]);
72
84
  for (let i = 0; i < argv.length; i++) {
73
85
  const arg = argv[i];
@@ -76,6 +88,7 @@ function parseArgs(argv) {
76
88
  if (arg === "--open") { opts.open = true; continue; }
77
89
  if (arg === "--config" && argv[i + 1]) { opts.config = argv[++i]; continue; }
78
90
  if (arg === "--json") { opts.json = true; continue; }
91
+ if (arg === "--live" || arg === "-w") { opts.live = true; continue; }
79
92
  if (arg === "--days" && argv[i + 1]) { opts.days = parseInt(argv[++i], 10); continue; }
80
93
  if (arg === "--lines" && argv[i + 1]) { opts.lines = parseInt(argv[++i], 10); continue; }
81
94
  if (arg === "--help" || arg === "-h") { opts.help = true; continue; }
@@ -117,6 +130,7 @@ if (opts.help) {
117
130
  --open Auto-open the dashboard in a browser
118
131
  --config <path> Custom path to openclaw.json
119
132
  --json Output as JSON (for stats, status, providers, history)
133
+ --live, -w Live auto-refreshing stats (for stats command)
120
134
  --days <number> Number of days to show in history (default: 7)
121
135
  --lines <number> Number of log lines to show (default: 50)
122
136
  --help, -h Show this help message
@@ -128,7 +142,8 @@ if (opts.help) {
128
142
  npx oc-inspector restart # Restart daemon
129
143
  npx oc-inspector enable # Enable interception
130
144
  npx oc-inspector disable # Disable interception
131
- npx oc-inspector stats # Show live token stats
145
+ npx oc-inspector stats # Show token stats
146
+ npx oc-inspector stats --live # Live auto-refreshing dashboard
132
147
  npx oc-inspector stats --json # Stats as JSON
133
148
  npx oc-inspector history --days 30 # Last 30 days
134
149
  npx oc-inspector pricing # Show pricing table
@@ -141,6 +156,15 @@ if (opts.help) {
141
156
  // Command routing
142
157
  // ═══════════════════════════════════════════════════════════════
143
158
 
159
+ // help: show commands
160
+ if (opts.command === "help") {
161
+ console.log("");
162
+ console.log(" \x1b[38;5;208m🦞 oc-inspector\x1b[0m — Real-time API traffic inspector for OpenClaw");
163
+ console.log("");
164
+ printCommandsHelp();
165
+ process.exit(0);
166
+ }
167
+
144
168
  // Hidden: _serve is the actual foreground server (spawned by start)
145
169
  if (opts.command === "_serve") {
146
170
  await runServe(opts);
@@ -212,8 +236,7 @@ async function runDaemonStart(opts) {
212
236
  console.log(` \x1b[33m⚠\x1b[0m Already running (PID ${running.pid})`);
213
237
  console.log(` \x1b[32m✓\x1b[0m Dashboard: http://127.0.0.1:${opts.port}`);
214
238
  console.log("");
215
- console.log(` Run \x1b[1moc-inspector restart\x1b[0m to restart`);
216
- console.log("");
239
+ printCommandsHelp();
217
240
  return;
218
241
  }
219
242
 
@@ -254,9 +277,7 @@ async function runDaemonStart(opts) {
254
277
  console.log(` \x1b[32m✓\x1b[0m Dashboard: http://127.0.0.1:${opts.port}`);
255
278
  console.log(` \x1b[32m✓\x1b[0m Log file: ${LOG_FILE}`);
256
279
  console.log("");
257
- console.log(` \x1b[90mStop with:\x1b[0m oc-inspector stop`);
258
- console.log(` \x1b[90mView logs:\x1b[0m oc-inspector logs`);
259
- console.log("");
280
+ printCommandsHelp();
260
281
  } else {
261
282
  console.log(` \x1b[31m✗ Failed to start — check logs:\x1b[0m`);
262
283
  console.log(` ${LOG_FILE}`);
@@ -516,6 +537,10 @@ async function runRemoteCommand(opts) {
516
537
  const base = `http://127.0.0.1:${opts.port}`;
517
538
 
518
539
  if (opts.command === "stats") {
540
+ if (opts.live) {
541
+ await runLiveStats(base);
542
+ return;
543
+ }
519
544
  const data = await fetchApi(`${base}/api/stats`);
520
545
  if (!data) return;
521
546
  if (opts.json) { console.log(JSON.stringify(data, null, 2)); return; }
@@ -564,6 +589,36 @@ async function runRemoteCommand(opts) {
564
589
  // Helpers / Printers
565
590
  // ═══════════════════════════════════════════════════════════════
566
591
 
592
+ /**
593
+ * Print a compact command reference table.
594
+ * Shown after successful daemon start and via `help` command.
595
+ */
596
+ function printCommandsHelp() {
597
+ const C = "\x1b[36m"; // cyan
598
+ const D = "\x1b[90m"; // dim
599
+ const R = "\x1b[0m"; // reset
600
+ const B = "\x1b[1m"; // bold
601
+
602
+ console.log(` ${B}Commands:${R}`);
603
+ console.log(` ${C}oc-inspector${R} Start daemon ${D}(default)${R}`);
604
+ console.log(` ${C}oc-inspector stop${R} Stop daemon`);
605
+ console.log(` ${C}oc-inspector restart${R} Restart daemon`);
606
+ console.log(` ${C}oc-inspector run${R} Foreground mode ${D}(interactive)${R}`);
607
+ console.log(` ${C}oc-inspector status${R} Daemon + interception status`);
608
+ console.log(` ${C}oc-inspector enable${R} Enable interception`);
609
+ console.log(` ${C}oc-inspector disable${R} Disable interception`);
610
+ console.log(` ${C}oc-inspector stats${R} Token/cost statistics ${D}(--live for auto-refresh)${R}`);
611
+ console.log(` ${C}oc-inspector history${R} Daily usage history`);
612
+ console.log(` ${C}oc-inspector pricing${R} Model pricing table`);
613
+ console.log(` ${C}oc-inspector providers${R} Active providers list`);
614
+ console.log(` ${C}oc-inspector config${R} Inspector config info`);
615
+ console.log(` ${C}oc-inspector logs${R} Daemon log output`);
616
+ console.log(` ${C}oc-inspector help${R} Show this help`);
617
+ console.log("");
618
+ console.log(` ${D}Use --json for machine-readable output. See oc-inspector --help for all options.${R}`);
619
+ console.log("");
620
+ }
621
+
567
622
  async function fetchApi(url) {
568
623
  try {
569
624
  const res = await fetch(url);
@@ -575,64 +630,214 @@ async function fetchApi(url) {
575
630
  }
576
631
  }
577
632
 
633
+ // ── Formatting helpers ──
634
+
578
635
  function fmtNum(n) {
579
636
  if (n >= 1_000_000) return (n / 1_000_000).toFixed(2) + "M";
580
637
  if (n >= 1_000) return (n / 1_000).toFixed(1) + "k";
581
638
  return String(n);
582
639
  }
583
640
 
584
- function printStats(s) {
585
- const bar = (label, val, max, width = 24) => {
586
- const pct = max > 0 ? Math.min(val / max, 1) : 0;
587
- const filled = Math.round(pct * width);
588
- const empty = width - filled;
589
- return `${label} ${"█".repeat(filled)}${"░".repeat(empty)} ${fmtNum(val)}`;
590
- };
641
+ function fmtCost(n) {
642
+ if (!n || n === 0) return "$0.00";
643
+ if (n < 0.01) return "$" + n.toFixed(4);
644
+ return "$" + n.toFixed(2);
645
+ }
591
646
 
592
- console.log("");
593
- console.log(" \x1b[38;5;208m🦞 OpenClaw Inspector Stats\x1b[0m");
594
- console.log(" " + "".repeat(50));
595
- console.log("");
647
+ function fmtMs(ms) {
648
+ if (ms >= 60_000) return (ms / 60_000).toFixed(1) + "m";
649
+ if (ms >= 1_000) return (ms / 1_000).toFixed(1) + "s";
650
+ return ms + "ms";
651
+ }
652
+
653
+ /**
654
+ * Build a horizontal bar string.
655
+ *
656
+ * @param {number} val - Current value.
657
+ * @param {number} max - Maximum value (100%).
658
+ * @param {number} [width=20] - Bar width in characters.
659
+ * @returns {string} Bar characters.
660
+ */
661
+ function bar(val, max, width = 20) {
662
+ const pct = max > 0 ? Math.min(val / max, 1) : 0;
663
+ const filled = Math.round(pct * width);
664
+ const empty = width - filled;
665
+ return "█".repeat(filled) + "░".repeat(empty);
666
+ }
667
+
668
+ /**
669
+ * Render stats data to an array of strings (no ANSI clears).
670
+ *
671
+ * @param {object} s - Stats from /api/stats.
672
+ * @param {object} [opts] - Render options.
673
+ * @param {boolean} [opts.compact=false] - Skip header/footer.
674
+ * @returns {string[]} Lines to print.
675
+ */
676
+ function renderStats(s, { compact = false } = {}) {
677
+ const lines = [];
678
+ const w = COL;
679
+
680
+ if (!compact) {
681
+ lines.push("");
682
+ lines.push(` ${E.ORG}🦞 OpenClaw Inspector — Stats${E.R}`);
683
+ lines.push(" " + "─".repeat(68));
684
+ }
685
+
686
+ // ── Summary box ──
687
+ lines.push("");
688
+ const errStr = s.errors > 0 ? ` ${E.RED}(${s.errors} errors)${E.R}` : "";
689
+ lines.push(` ${E.B}Requests${E.R} ${String(s.totalRequests).padStart(6)}${errStr}`);
690
+
691
+ const inTok = fmtNum(s.totalInputTokens);
692
+ const outTok = fmtNum(s.totalOutputTokens);
693
+ lines.push(` ${E.B}Tokens${E.R} ${fmtNum(s.totalTokens).padStart(6)} ${E.D}in ${inTok} out ${outTok}${E.R}`);
596
694
 
597
- console.log(` \x1b[1mRequests:\x1b[0m ${s.totalRequests}${s.errors > 0 ? ` (\x1b[31m${s.errors} errors\x1b[0m)` : ""}`);
598
- console.log(` \x1b[1mTokens:\x1b[0m ${fmtNum(s.totalTokens)} total (in: ${fmtNum(s.totalInputTokens)} out: ${fmtNum(s.totalOutputTokens)})`);
599
695
  if (s.totalCachedTokens > 0) {
600
- console.log(` \x1b[1mCached:\x1b[0m ${fmtNum(s.totalCachedTokens)}`);
696
+ lines.push(` ${E.B}Cached${E.R} ${fmtNum(s.totalCachedTokens).padStart(6)}`);
601
697
  }
602
- console.log(` \x1b[1mCost:\x1b[0m \x1b[32m$${(s.totalCost || 0).toFixed(4)}\x1b[0m`);
603
- if (s.totalDuration > 0) {
604
- const avgMs = Math.round(s.totalDuration / s.totalRequests);
605
- console.log(` \x1b[1mAvg latency:\x1b[0m ${avgMs}ms`);
698
+
699
+ lines.push(` ${E.B}Cost${E.R} ${E.GRN}${fmtCost(s.totalCost || 0).padStart(8)}${E.R}`);
700
+
701
+ if (s.totalDuration > 0 && s.totalRequests > 0) {
702
+ const avg = Math.round(s.totalDuration / s.totalRequests);
703
+ lines.push(` ${E.B}Avg latency${E.R} ${fmtMs(avg).padStart(6)}`);
606
704
  }
607
705
 
608
- const providers = Object.entries(s.byProvider);
706
+ // ── By Provider ──
707
+ const providers = Object.entries(s.byProvider || {});
609
708
  if (providers.length > 0) {
610
- console.log("");
611
- console.log(" \x1b[1mBy Provider\x1b[0m");
612
- console.log(" " + "─".repeat(50));
613
- const maxTokens = Math.max(...providers.map(([, v]) => v.inputTokens + v.outputTokens));
614
- for (const [name, v] of providers.sort((a, b) => (b[1].cost || 0) - (a[1].cost || 0) || (b[1].inputTokens + b[1].outputTokens) - (a[1].inputTokens + a[1].outputTokens))) {
615
- const total = v.inputTokens + v.outputTokens;
616
- const costStr = v.cost > 0 ? ` \x1b[32m$${v.cost.toFixed(4)}\x1b[0m` : "";
617
- console.log(` \x1b[36m${name.padEnd(18)}\x1b[0m ${bar("", total, maxTokens)} (${v.requests} reqs)${costStr}`);
709
+ lines.push("");
710
+ lines.push(` ${E.B}${"Provider".padEnd(w.name)} ${"Tokens".padStart(w.tokens)} ${"Bar".padEnd(w.bar)} ${"Reqs".padStart(w.reqs)} ${"Cost".padStart(w.cost)}${E.R}`);
711
+ lines.push(" " + "─".repeat(68));
712
+
713
+ const maxT = Math.max(...providers.map(([, v]) => (v.inputTokens || 0) + (v.outputTokens || 0)));
714
+ const sorted = providers.sort((a, b) =>
715
+ (b[1].cost || 0) - (a[1].cost || 0) ||
716
+ ((b[1].inputTokens || 0) + (b[1].outputTokens || 0)) - ((a[1].inputTokens || 0) + (a[1].outputTokens || 0))
717
+ );
718
+
719
+ for (const [name, v] of sorted) {
720
+ const total = (v.inputTokens || 0) + (v.outputTokens || 0);
721
+ const n = name.length > w.name - 2 ? name.slice(0, w.name - 3) + "…" : name;
722
+ const costStr = v.cost > 0 ? `${E.GRN}${fmtCost(v.cost).padStart(w.cost)}${E.R}` : `${E.D}${fmtCost(0).padStart(w.cost)}${E.R}`;
723
+ lines.push(` ${E.CYN}${n.padEnd(w.name)}${E.R} ${fmtNum(total).padStart(w.tokens)} ${bar(total, maxT, w.bar)} ${String(v.requests).padStart(w.reqs)} ${costStr}`);
618
724
  }
619
725
  }
620
726
 
621
- const models = Object.entries(s.byModel);
727
+ // ── By Model ──
728
+ const models = Object.entries(s.byModel || {});
622
729
  if (models.length > 0) {
623
- console.log("");
624
- console.log(" \x1b[1mBy Model\x1b[0m");
625
- console.log(" " + "─".repeat(50));
626
- const maxTokens = Math.max(...models.map(([, v]) => v.inputTokens + v.outputTokens));
627
- for (const [name, v] of models.sort((a, b) => (b[1].cost || 0) - (a[1].cost || 0) || (b[1].inputTokens + b[1].outputTokens) - (a[1].inputTokens + a[1].outputTokens))) {
628
- const total = v.inputTokens + v.outputTokens;
629
- const shortName = name.length > 28 ? name.slice(0, 27) + "…" : name;
630
- const costStr = v.cost > 0 ? ` \x1b[32m$${v.cost.toFixed(4)}\x1b[0m` : "";
631
- console.log(` \x1b[33m${shortName.padEnd(30)}\x1b[0m ${bar("", total, maxTokens)} (${v.requests} reqs)${costStr}`);
730
+ lines.push("");
731
+ lines.push(` ${E.B}${"Model".padEnd(w.name)} ${"Tokens".padStart(w.tokens)} ${"Bar".padEnd(w.bar)} ${"Reqs".padStart(w.reqs)} ${"Cost".padStart(w.cost)}${E.R}`);
732
+ lines.push(" " + "─".repeat(68));
733
+
734
+ const maxT = Math.max(...models.map(([, v]) => (v.inputTokens || 0) + (v.outputTokens || 0)));
735
+ const sorted = models.sort((a, b) =>
736
+ (b[1].cost || 0) - (a[1].cost || 0) ||
737
+ ((b[1].inputTokens || 0) + (b[1].outputTokens || 0)) - ((a[1].inputTokens || 0) + (a[1].outputTokens || 0))
738
+ );
739
+
740
+ for (const [name, v] of sorted) {
741
+ const total = (v.inputTokens || 0) + (v.outputTokens || 0);
742
+ const n = name.length > w.name - 2 ? name.slice(0, w.name - 3) + "…" : name;
743
+ const costStr = v.cost > 0 ? `${E.GRN}${fmtCost(v.cost).padStart(w.cost)}${E.R}` : `${E.D}${fmtCost(0).padStart(w.cost)}${E.R}`;
744
+ lines.push(` ${E.YEL}${n.padEnd(w.name)}${E.R} ${fmtNum(total).padStart(w.tokens)} ${bar(total, maxT, w.bar)} ${String(v.requests).padStart(w.reqs)} ${costStr}`);
632
745
  }
633
746
  }
634
747
 
635
- console.log("");
748
+ lines.push("");
749
+ return lines;
750
+ }
751
+
752
+ function printStats(s) {
753
+ for (const line of renderStats(s)) process.stdout.write(line + "\n");
754
+ }
755
+
756
+ /**
757
+ * Live auto-refreshing stats dashboard.
758
+ * Clears the terminal and redraws every 2 seconds.
759
+ * Press q or Ctrl+C to exit.
760
+ *
761
+ * @param {string} base - Inspector API base URL.
762
+ */
763
+ async function runLiveStats(base) {
764
+ const url = `${base}/api/stats`;
765
+
766
+ // Enable raw mode for keypress detection
767
+ if (process.stdin.isTTY) {
768
+ process.stdin.setRawMode(true);
769
+ process.stdin.resume();
770
+ process.stdin.setEncoding("utf8");
771
+ }
772
+
773
+ process.stdout.write(E.HIDE); // hide cursor
774
+
775
+ let running = true;
776
+ let tick = 0;
777
+
778
+ const cleanup = () => {
779
+ running = false;
780
+ process.stdout.write(E.SHOW); // show cursor
781
+ if (process.stdin.isTTY) process.stdin.setRawMode(false);
782
+ console.log("");
783
+ process.exit(0);
784
+ };
785
+
786
+ process.on("SIGINT", cleanup);
787
+ process.on("SIGTERM", cleanup);
788
+
789
+ if (process.stdin.isTTY) {
790
+ process.stdin.on("data", (key) => {
791
+ if (key === "q" || key === "Q" || key === "\x03") cleanup(); // q or Ctrl+C
792
+ });
793
+ }
794
+
795
+ while (running) {
796
+ try {
797
+ const res = await fetch(url);
798
+ const data = await res.json();
799
+
800
+ const lines = [];
801
+ lines.push(E.CLR); // clear screen
802
+ lines.push(` ${E.ORG}🦞 OpenClaw Inspector${E.R} ${E.D}— Live Stats${E.R}`);
803
+ lines.push(" " + "═".repeat(68));
804
+
805
+ // Uptime spinner
806
+ const spinner = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
807
+ const sp = spinner[tick % spinner.length];
808
+ const now = new Date().toLocaleTimeString();
809
+ lines.push(` ${E.GRN}${sp}${E.R} ${E.D}Updated ${now} Press ${E.B}q${E.R}${E.D} to exit${E.R}`);
810
+
811
+ // Render stats body (compact, no header)
812
+ const body = renderStats(data, { compact: true });
813
+ lines.push(...body);
814
+
815
+ // Recently seen models (last 5 entries by model)
816
+ const recent = data.recentEntries || [];
817
+ if (recent.length > 0) {
818
+ lines.push(` ${E.B}Recent Requests${E.R}`);
819
+ lines.push(" " + "─".repeat(68));
820
+ for (const e of recent.slice(0, 8)) {
821
+ const model = (e.model || "?").length > 22 ? (e.model || "?").slice(0, 21) + "…" : (e.model || "?");
822
+ const st = e.state === "done" ? `${E.GRN}✓${E.R}` : e.state === "error" ? `${E.RED}✗${E.R}` : `${E.YEL}…${E.R}`;
823
+ const dur = e.duration ? fmtMs(e.duration) : "—";
824
+ const tok = e.totalTokens ? fmtNum(e.totalTokens) : "—";
825
+ const cost = e.cost > 0 ? `${E.GRN}${fmtCost(e.cost)}${E.R}` : `${E.D}—${E.R}`;
826
+ lines.push(` ${st} ${E.YEL}${model.padEnd(24)}${E.R} ${E.CYN}${(e.provider || "?").padEnd(12)}${E.R} ${tok.padStart(8)} tok ${dur.padStart(6)} ${cost}`);
827
+ }
828
+ lines.push("");
829
+ }
830
+
831
+ process.stdout.write(lines.join("\n") + "\n");
832
+ } catch {
833
+ process.stdout.write(E.CLR);
834
+ process.stdout.write(`\n ${E.RED}✗ Cannot connect to inspector at ${base}${E.R}\n`);
835
+ process.stdout.write(` ${E.D}Retrying...${E.R}\n`);
836
+ }
837
+
838
+ tick++;
839
+ await new Promise((r) => setTimeout(r, 2000));
840
+ }
636
841
  }
637
842
 
638
843
  function printPricing(models) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oc-inspector",
3
- "version": "1.3.0",
3
+ "version": "1.3.1",
4
4
  "description": "Real-time API traffic inspector for OpenClaw — intercepts LLM provider requests, shows token usage, costs, and message flow in a live web dashboard.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/proxy.mjs CHANGED
@@ -112,6 +112,24 @@ export function getStats() {
112
112
  }
113
113
  }
114
114
 
115
+ // Recent entries (last 10) for live view
116
+ const recentEntries = [];
117
+ const recentIds = entryOrder.slice(-10).reverse();
118
+ for (const id of recentIds) {
119
+ const e = entries.get(id);
120
+ if (!e) continue;
121
+ const u = e.usage || {};
122
+ recentEntries.push({
123
+ id: e.id,
124
+ provider: e.provider || "?",
125
+ model: u.model || e.reqModel || "?",
126
+ state: e.state,
127
+ duration: e.duration || 0,
128
+ totalTokens: (u.inputTokens || 0) + (u.outputTokens || 0),
129
+ cost: e.cost || 0,
130
+ });
131
+ }
132
+
115
133
  return {
116
134
  totalRequests,
117
135
  totalInputTokens,
@@ -123,6 +141,7 @@ export function getStats() {
123
141
  errors,
124
142
  byProvider,
125
143
  byModel,
144
+ recentEntries,
126
145
  };
127
146
  }
128
147