routstrd 0.2.17 → 0.2.18

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.
@@ -42670,6 +42670,18 @@ async function buildWalletDetails(deps) {
42670
42670
  activeMint: deps.walletAdapter.getActiveMintUrl()
42671
42671
  };
42672
42672
  }
42673
+ function makeSdkLogger(prefix) {
42674
+ const tag = prefix ? `[${prefix}]` : undefined;
42675
+ const fmt = (...args) => tag ? [tag, ...args] : args;
42676
+ return {
42677
+ log: (...args) => logger3.log(...fmt(...args)),
42678
+ warn: (...args) => logger3.log(...fmt(...args)),
42679
+ error: (...args) => logger3.error(...fmt(...args)),
42680
+ debug: (...args) => logger3.debug(...fmt(...args)),
42681
+ child: (p) => makeSdkLogger(prefix ? `${prefix}:${p}` : p)
42682
+ };
42683
+ }
42684
+ var sdkLogger = makeSdkLogger();
42673
42685
  function createDaemonRequestHandler(deps) {
42674
42686
  return async function handler(req, res) {
42675
42687
  const host = req.headers.host || "localhost";
@@ -43242,7 +43254,9 @@ function createDaemonRequestHandler(deps) {
43242
43254
  }
43243
43255
  try {
43244
43256
  await deps.ensureProvidersBootstrapped();
43245
- logger3.log("Routing request with path: ", url2.pathname);
43257
+ const reqId = randomBytes6(4).toString("hex");
43258
+ const reqLogger = sdkLogger.child(`req:${reqId}`);
43259
+ logger3.log(`[req:${reqId}] Routing request with path: `, url2.pathname);
43246
43260
  const response = await routeRequests({
43247
43261
  modelId,
43248
43262
  requestBody,
@@ -43258,7 +43272,8 @@ function createDaemonRequestHandler(deps) {
43258
43272
  mode: deps.mode,
43259
43273
  usageTrackingDriver: deps.usageTrackingDriver,
43260
43274
  sdkStore: deps.store,
43261
- providerManager: deps.providerManager
43275
+ providerManager: deps.providerManager,
43276
+ logger: reqLogger
43262
43277
  });
43263
43278
  res.statusCode = response.status;
43264
43279
  response.headers.forEach((value, key) => {
@@ -43341,6 +43356,18 @@ async function refreshModelsAndIntegrations(getRoutstr21Models, config, label =
43341
43356
 
43342
43357
  // src/daemon/index.ts
43343
43358
  init_dist3();
43359
+ function makeSdkLogger2(prefix) {
43360
+ const tag = prefix ? `[${prefix}]` : undefined;
43361
+ const fmt = (...args) => tag ? [tag, ...args] : args;
43362
+ return {
43363
+ log: (...args) => logger3.log(...fmt(...args)),
43364
+ warn: (...args) => logger3.log(...fmt(...args)),
43365
+ error: (...args) => logger3.error(...fmt(...args)),
43366
+ debug: (...args) => logger3.debug(...fmt(...args)),
43367
+ child: (p) => makeSdkLogger2(prefix ? `${prefix}:${p}` : p)
43368
+ };
43369
+ }
43370
+ var daemonSdkLogger = makeSdkLogger2();
43344
43371
  async function main() {
43345
43372
  const args = parseArgs(process.argv);
43346
43373
  const config = await loadDaemonConfig();
@@ -43349,7 +43376,7 @@ async function main() {
43349
43376
  await ensureDirs();
43350
43377
  const updatedConfig = { ...config, port, provider };
43351
43378
  saveDaemonConfig(updatedConfig);
43352
- const sqliteDriver = await createBunSqliteDriver2(DB_PATH);
43379
+ const sqliteDriver = await createBunSqliteDriver2(DB_PATH, { logger: daemonSdkLogger });
43353
43380
  const { store, hydrate } = createSdkStore({ driver: sqliteDriver });
43354
43381
  await hydrate;
43355
43382
  const { Database } = await import("bun:sqlite");
@@ -43359,10 +43386,10 @@ async function main() {
43359
43386
  legacyStorageDriver: sqliteDriver
43360
43387
  });
43361
43388
  const discoveryAdapter = createDiscoveryAdapterFromStore(store);
43362
- const providerRegistry = createProviderRegistryFromStore(store);
43389
+ const providerRegistry = createProviderRegistryFromStore(store, daemonSdkLogger);
43363
43390
  const storageAdapter = createStorageAdapterFromStore(store);
43364
- const modelManager = new ModelManager(discoveryAdapter);
43365
- const providerManager = new ProviderManager(providerRegistry, store);
43391
+ const modelManager = new ModelManager(discoveryAdapter, { logger: daemonSdkLogger });
43392
+ const providerManager = new ProviderManager(providerRegistry, store, daemonSdkLogger);
43366
43393
  const { ensureProvidersBootstrapped, getRoutstr21Models, getModelProviders } = createModelService(modelManager, store);
43367
43394
  const walletClient = createCocodClient({ cocodPath: config.cocodPath });
43368
43395
  const walletAdapter = await createWalletAdapter({
package/dist/index.js CHANGED
@@ -2302,14 +2302,6 @@ var init_process_lock = () => {
2302
2302
  };
2303
2303
 
2304
2304
  // src/start-daemon.ts
2305
- import { existsSync as existsSync2 } from "fs";
2306
- function getTodayLogFile() {
2307
- const now = new Date;
2308
- const year = now.getFullYear();
2309
- const month = String(now.getMonth() + 1).padStart(2, "0");
2310
- const day = String(now.getDate()).padStart(2, "0");
2311
- return `${LOGS_DIR2}/${year}-${month}-${day}.log`;
2312
- }
2313
2305
  async function isDaemonHealthy(port) {
2314
2306
  const controller = new AbortController;
2315
2307
  const timeoutId = setTimeout(() => controller.abort(), 2000);
@@ -2339,12 +2331,8 @@ async function startDaemonUnlocked(options) {
2339
2331
  if (options.provider) {
2340
2332
  args.push("--provider", options.provider);
2341
2333
  }
2342
- if (!existsSync2(LOGS_DIR2)) {
2343
- await Bun.$`mkdir -p ${LOGS_DIR2}`;
2344
- }
2345
2334
  const daemonScript = new URL("./daemon/index.js", import.meta.url).pathname;
2346
- const todayLogFile = getTodayLogFile();
2347
- const shellCmd = `bun run "${daemonScript}" ${args.map((a) => `'${a}'`).join(" ")} >> "${todayLogFile}" 2>&1`;
2335
+ const shellCmd = `bun run "${daemonScript}" ${args.map((a) => `'${a}'`).join(" ")}`;
2348
2336
  const proc = Bun.spawn(["sh", "-c", shellCmd], {
2349
2337
  stdout: "inherit",
2350
2338
  stderr: "inherit",
@@ -9380,10 +9368,10 @@ var init_nip98 = __esm(() => {
9380
9368
  });
9381
9369
 
9382
9370
  // src/utils/daemon-client.ts
9383
- import { existsSync as existsSync3 } from "fs";
9371
+ import { existsSync as existsSync2 } from "fs";
9384
9372
  async function loadConfig() {
9385
9373
  try {
9386
- if (existsSync3(CONFIG_FILE)) {
9374
+ if (existsSync2(CONFIG_FILE)) {
9387
9375
  const content = await Bun.file(CONFIG_FILE).text();
9388
9376
  return { ...DEFAULT_CONFIG, ...JSON.parse(content) };
9389
9377
  }
@@ -13681,17 +13669,23 @@ var require_browser = __commonJS((exports) => {
13681
13669
  });
13682
13670
 
13683
13671
  // src/tui/usage/constants.ts
13684
- var TABS, COLORS, MODEL_COLORS, CLIENT_COLORS;
13672
+ function getVisibleTabs(hasNpubs) {
13673
+ const tabs = ALL_TABS.filter((t) => t.id !== "npubs" || hasNpubs);
13674
+ return tabs.map((t, i2) => ({ ...t, key: String(i2 + 1) }));
13675
+ }
13676
+ var ALL_TABS, TABS, COLORS, MODEL_COLORS, CLIENT_COLORS;
13685
13677
  var init_constants = __esm(() => {
13686
- TABS = [
13678
+ ALL_TABS = [
13687
13679
  { id: "overview", name: "Overview", key: "1" },
13688
13680
  { id: "today", name: "Today", key: "2" },
13689
13681
  { id: "models", name: "Models", key: "3" },
13690
13682
  { id: "providers", name: "Providers", key: "4" },
13691
13683
  { id: "tokens", name: "Tokens", key: "5" },
13692
13684
  { id: "clients", name: "Clients", key: "6" },
13693
- { id: "recent", name: "Recent", key: "7" }
13685
+ { id: "npubs", name: "Npubs", key: "7" },
13686
+ { id: "recent", name: "Recent", key: "8" }
13694
13687
  ];
13688
+ TABS = getVisibleTabs(false);
13695
13689
  COLORS = {
13696
13690
  reset: "\x1B[0m",
13697
13691
  bold: "\x1B[1m",
@@ -13915,6 +13909,58 @@ function getClientStats(entries) {
13915
13909
  }
13916
13910
  return Array.from(clients.values()).sort((a, b) => b.satsCost - a.satsCost);
13917
13911
  }
13912
+ async function fetchClients() {
13913
+ try {
13914
+ const running = await isDaemonRunning();
13915
+ if (!running)
13916
+ return [];
13917
+ const result = await callDaemon("/clients");
13918
+ if (result.error)
13919
+ return [];
13920
+ const output = result.output;
13921
+ return (output?.clients || []).map((c) => ({
13922
+ clientId: c.id,
13923
+ name: c.name,
13924
+ ownerNpub: c.ownerNpub
13925
+ }));
13926
+ } catch {
13927
+ return [];
13928
+ }
13929
+ }
13930
+ function hasAnyNpubs(clients) {
13931
+ return clients.some((c) => !!c.ownerNpub);
13932
+ }
13933
+ function getNpubStats(entries, clients) {
13934
+ const clientNpubMap = new Map;
13935
+ for (const c of clients) {
13936
+ if (c.ownerNpub) {
13937
+ clientNpubMap.set(c.clientId, c.ownerNpub);
13938
+ }
13939
+ }
13940
+ const npubs = new Map;
13941
+ for (const entry of entries) {
13942
+ const npub = clientNpubMap.get(entry.client || "");
13943
+ if (!npub)
13944
+ continue;
13945
+ const existing = npubs.get(npub) || {
13946
+ npub,
13947
+ requests: 0,
13948
+ satsCost: 0,
13949
+ promptTokens: 0,
13950
+ completionTokens: 0,
13951
+ totalTokens: 0
13952
+ };
13953
+ npubs.set(npub, {
13954
+ ...existing,
13955
+ requests: existing.requests + 1,
13956
+ satsCost: existing.satsCost + entry.satsCost,
13957
+ promptTokens: existing.promptTokens + entry.promptTokens,
13958
+ completionTokens: existing.completionTokens + entry.completionTokens,
13959
+ totalTokens: existing.totalTokens + entry.totalTokens
13960
+ });
13961
+ }
13962
+ return Array.from(npubs.values()).sort((a, b) => b.satsCost - a.satsCost);
13963
+ }
13918
13964
  function getTotals(entries) {
13919
13965
  if (!entries || !Array.isArray(entries)) {
13920
13966
  return { promptTokens: 0, completionTokens: 0, totalTokens: 0, satsCost: 0 };
@@ -14061,10 +14107,11 @@ function formatReqs(value) {
14061
14107
  return (value / 1000).toFixed(1) + "k";
14062
14108
  return value.toString();
14063
14109
  }
14064
- function renderHeader(activeTab, width) {
14110
+ function renderHeader(activeTab, width, visibleTabs) {
14065
14111
  const title = `${COLORS.bold}${COLORS.cyan}ROUTSTRD USAGE MONITOR${COLORS.reset}`;
14066
14112
  const vimIndicator = `${COLORS.yellow}[vim]${COLORS.reset}`;
14067
- const help = `${COLORS.dim}[Q] Quit [\u2191\u2193] Scroll [\u2190\u2192] Tabs [1-7] Tabs [R] Refresh${COLORS.reset}`;
14113
+ const maxKey = visibleTabs.length;
14114
+ const help = `${COLORS.dim}[Q] Quit [\u2191\u2193] Scroll [\u2190\u2192] Tabs [1-${maxKey}] Tabs [R] Refresh${COLORS.reset}`;
14068
14115
  const fill = width - title.length - help.length - vimIndicator.length - 6;
14069
14116
  return `${title}${vimIndicator}${" ".repeat(Math.max(1, fill))}${help}
14070
14117
  `;
@@ -14080,8 +14127,8 @@ function renderSearchBar() {
14080
14127
  ${searchLine}${placeholder}
14081
14128
  `;
14082
14129
  }
14083
- function renderTabs(activeTab) {
14084
- const tabStr = TABS.map((tab) => tab.id === activeTab ? `${COLORS.bgBlue} ${tab.key}:${tab.name} ${COLORS.reset}` : `${COLORS.dim}[${tab.key}]${COLORS.reset} ${tab.name}`).join(" ");
14130
+ function renderTabs(activeTab, visibleTabs) {
14131
+ const tabStr = visibleTabs.map((tab) => tab.id === activeTab ? `${COLORS.bgBlue} ${tab.key}:${tab.name} ${COLORS.reset}` : `${COLORS.dim}[${tab.key}]${COLORS.reset} ${tab.name}`).join(" ");
14085
14132
  return `${" ".repeat(2)}${tabStr}
14086
14133
  `;
14087
14134
  }
@@ -14452,28 +14499,106 @@ function renderClients(stats, width) {
14452
14499
  }
14453
14500
  return output;
14454
14501
  }
14502
+ function renderNpubs(stats, clients, width) {
14503
+ const npubStats = getNpubStats(stats.entries, clients);
14504
+ if (npubStats.length === 0)
14505
+ return renderBox(["No npub data available"], width, "Npub Breakdown");
14506
+ const totalCost = stats.totalSatsCost;
14507
+ const maxCost = npubStats[0].satsCost;
14508
+ const lines = [];
14509
+ const col1 = 24;
14510
+ const col2 = 12;
14511
+ const col3 = 24;
14512
+ const col4 = 12;
14513
+ const hNpub = "Npub".padEnd(col1);
14514
+ const hReqs = "Requests".padEnd(col2);
14515
+ const hCost = "Cost".padEnd(col3);
14516
+ const hTok = "Tokens".padEnd(col4);
14517
+ lines.push(`${COLORS.bold}${hNpub}${hReqs}${hCost}${hTok}Avg Cost${COLORS.reset}`);
14518
+ lines.push(COLORS.dim + "\u2500".repeat(Math.max(0, width - 4)) + COLORS.reset);
14519
+ startBarSection("npub-detail", col1);
14520
+ for (const npub of npubStats) {
14521
+ const pct = totalCost > 0 ? (npub.satsCost / totalCost * 100).toFixed(1) : "0.0";
14522
+ const avgCostFormatted = formatCost(npub.requests > 0 ? npub.satsCost / npub.requests : 0);
14523
+ const shortNpub = truncateNpub(npub.npub);
14524
+ const dNpub = shortNpub.padEnd(col1);
14525
+ const dReqs = formatReqs(npub.requests).padEnd(col2);
14526
+ const dCost = `${formatCost(npub.satsCost)} sats (${pct}%)`.padEnd(col3);
14527
+ const dTok = formatNumber(npub.totalTokens).padEnd(col4);
14528
+ const dAvg = `${avgCostFormatted} sats/req`;
14529
+ lines.push(`${COLORS.magenta}${COLORS.bold}${dNpub}${COLORS.reset}` + `${dReqs}` + `${COLORS.green}${dCost}${COLORS.reset}` + `${COLORS.dim}${dTok}${dAvg}${COLORS.reset}`);
14530
+ lines.push(` ${COLORS.dim}${npub.npub}${COLORS.reset}`);
14531
+ lines.push(` ${renderBarChart("", npub.satsCost, maxCost, width - 6, COLORS.magenta, Number(pct), "npub-detail")}`);
14532
+ lines.push("");
14533
+ }
14534
+ endBarSection("npub-detail");
14535
+ let output = renderBox(lines, width, "Npub Breakdown");
14536
+ const npubModelMap = new Map;
14537
+ const clientNpubMap = new Map;
14538
+ for (const c of clients) {
14539
+ if (c.ownerNpub)
14540
+ clientNpubMap.set(c.clientId, c.ownerNpub);
14541
+ }
14542
+ for (const entry of stats.entries) {
14543
+ const npub = clientNpubMap.get(entry.client || "");
14544
+ if (!npub)
14545
+ continue;
14546
+ const model = entry.modelId;
14547
+ if (!npubModelMap.has(npub))
14548
+ npubModelMap.set(npub, new Map);
14549
+ const modelMap = npubModelMap.get(npub);
14550
+ const existing = modelMap.get(model) || { requests: 0, satsCost: 0, tokens: 0 };
14551
+ modelMap.set(model, {
14552
+ requests: existing.requests + 1,
14553
+ satsCost: existing.satsCost + entry.satsCost,
14554
+ tokens: existing.tokens + entry.totalTokens
14555
+ });
14556
+ }
14557
+ const npubModelLines = [];
14558
+ for (const topNpub of npubStats.slice(0, 5)) {
14559
+ const modelMap = npubModelMap.get(topNpub.npub);
14560
+ if (!modelMap)
14561
+ continue;
14562
+ const models = Array.from(modelMap.entries()).sort((a, b) => b[1].satsCost - a[1].satsCost).slice(0, 5);
14563
+ npubModelLines.push(`${COLORS.bold}${truncateNpub(topNpub.npub)}${COLORS.reset} (${formatReqs(topNpub.requests)} reqs, ${formatCost(topNpub.satsCost)} sats)`);
14564
+ for (const [model, data] of models) {
14565
+ npubModelLines.push(` ${MODEL_COLORS[model] || MODEL_COLORS.default}${model.padEnd(18)}${COLORS.reset} ${formatNumber(data.tokens).padEnd(8)} tokens ${formatCost(data.satsCost)} sats`);
14566
+ }
14567
+ npubModelLines.push("");
14568
+ }
14569
+ if (npubModelLines.length > 0) {
14570
+ output += `
14571
+ ` + renderBox(npubModelLines, width, "Top Models per Npub");
14572
+ }
14573
+ return output;
14574
+ }
14575
+ function truncateNpub(npub) {
14576
+ if (npub.length <= 24)
14577
+ return npub;
14578
+ return npub.slice(0, 10) + "\u2026" + npub.slice(-6);
14579
+ }
14455
14580
  function renderRecent(stats, width) {
14456
14581
  const recentEntries = stats.entries.slice(0, 50);
14457
14582
  if (recentEntries.length === 0)
14458
14583
  return renderBox(["No recent entries"], width, "Recent Requests");
14459
- const clientCol = 10;
14584
+ const clientCol = 14;
14460
14585
  const lines = [];
14461
- lines.push(`${COLORS.bold}${"TIME".padEnd(10)} ${"CLIENT".padEnd(clientCol)} ${"MODEL".padEnd(18)} ${"TOKENS".padEnd(10)} ${"COST".padEnd(12)} ${"PROVIDER".slice(0, Math.max(0, width - 70))}${COLORS.reset}`);
14586
+ lines.push(`${COLORS.bold}${"TIME".padEnd(10)} ${"MODEL".padEnd(18)} ${"TOKENS".padEnd(10)} ${"COST".padEnd(12)} ${"PROVIDER".padEnd(16)} ${"CLIENT".slice(0, clientCol)}${COLORS.reset}`);
14462
14587
  lines.push(COLORS.dim + "\u2500".repeat(width - 4) + COLORS.reset);
14463
14588
  for (const entry of recentEntries) {
14464
14589
  const time = formatTime(entry.timestamp).slice(0, 8);
14465
- const clientName = (entry.client || "unknown").slice(0, clientCol - 1).padEnd(clientCol);
14466
- const clientColor = CLIENT_COLORS[entry.client || "unknown"] || CLIENT_COLORS.default || COLORS.white;
14467
14590
  const model = entry.modelId.slice(0, 18).padEnd(18);
14468
14591
  const tokens = `${formatNumber(entry.totalTokens).padEnd(6)} (${formatNumber(entry.promptTokens)}+${formatNumber(entry.completionTokens)})`;
14469
14592
  const cost = `${formatCost(entry.satsCost).padEnd(8)} sats`;
14470
- const provider = (entry.baseUrl || "unknown").replace("https://", "").replace("http://", "").slice(0, Math.max(0, width - 70));
14593
+ const provider = (entry.baseUrl || "unknown").replace("https://", "").replace("http://", "").slice(0, 16).padEnd(16);
14594
+ const clientName = (entry.client || "unknown").slice(0, clientCol - 1);
14595
+ const clientColor = CLIENT_COLORS[entry.client || "unknown"] || CLIENT_COLORS.default || COLORS.white;
14471
14596
  const modelColor = MODEL_COLORS[entry.modelId] || MODEL_COLORS.default;
14472
- lines.push(`${COLORS.dim}${time}${COLORS.reset} ${clientColor}${clientName}${COLORS.reset} ${modelColor}${model}${COLORS.reset} ${tokens.padEnd(10)} ${COLORS.green}${cost}${COLORS.reset} ${COLORS.dim}${provider}${COLORS.reset}`);
14597
+ lines.push(`${COLORS.dim}${time}${COLORS.reset} ${modelColor}${model}${COLORS.reset} ${tokens.padEnd(10)} ${COLORS.green}${cost}${COLORS.reset} ${COLORS.dim}${provider}${COLORS.reset} ${clientColor}${clientName}${COLORS.reset}`);
14473
14598
  }
14474
14599
  return renderBox(lines, width, `Recent Requests (${stats.entries.length} shown)`);
14475
14600
  }
14476
- function renderTabContent(activeTab, stats, balance, status, width) {
14601
+ function renderTabContent(activeTab, stats, balance, status, width, clients = []) {
14477
14602
  switch (activeTab) {
14478
14603
  case "overview":
14479
14604
  return renderOverview(stats, balance, status, width);
@@ -14487,6 +14612,8 @@ function renderTabContent(activeTab, stats, balance, status, width) {
14487
14612
  return renderTokens(stats, width);
14488
14613
  case "clients":
14489
14614
  return renderClients(stats, width);
14615
+ case "npubs":
14616
+ return renderNpubs(stats, clients, width);
14490
14617
  case "recent":
14491
14618
  return renderRecent(stats, width);
14492
14619
  default:
@@ -14516,6 +14643,8 @@ async function runUsageTui() {
14516
14643
  let stats = null;
14517
14644
  let balance = null;
14518
14645
  let status = null;
14646
+ let clients = [];
14647
+ let visibleTabs = getVisibleTabs(false);
14519
14648
  let refreshInterval = null;
14520
14649
  let shouldUpdate = true;
14521
14650
  let autoRefresh = true;
@@ -14555,6 +14684,13 @@ async function runUsageTui() {
14555
14684
  stats = await fetchUsage(1e4);
14556
14685
  balance = await fetchBalance();
14557
14686
  status = await fetchStatus();
14687
+ clients = await fetchClients();
14688
+ const npubsVisible = hasAnyNpubs(clients);
14689
+ visibleTabs = getVisibleTabs(npubsVisible);
14690
+ if (currentTab === "npubs" && !npubsVisible) {
14691
+ currentTab = "clients";
14692
+ vimState.scrollPos = 0;
14693
+ }
14558
14694
  shouldUpdate = false;
14559
14695
  }
14560
14696
  if (!stats) {
@@ -14564,9 +14700,9 @@ async function runUsageTui() {
14564
14700
  Press Q to quit.`);
14565
14701
  return;
14566
14702
  }
14567
- const content = renderTabContent(currentTab, stats, balance, status, width);
14703
+ const content = renderTabContent(currentTab, stats, balance, status, width, clients);
14568
14704
  const footer = `${COLORS.dim}Press [Q] to quit, [R] to refresh, [A] to toggle auto-refresh${autoRefresh ? " (on)" : " (off)"} scroll:${vimState.scrollPos}${COLORS.reset}${vimState.mode === "normal" ? ` ${COLORS.yellow}vim: hjkl/arrows, / search, g top, gg bottom${COLORS.reset}` : ""}`;
14569
- const chrome = renderHeader(currentTab, width) + renderTabs(currentTab) + renderSeparator(width) + renderSearchBar();
14705
+ const chrome = renderHeader(currentTab, width, visibleTabs) + renderTabs(currentTab, visibleTabs) + renderSeparator(width) + renderSearchBar();
14570
14706
  const chromeLines = chrome.split(`
14571
14707
  `).length - 1;
14572
14708
  const footerSeparator = renderSeparator(width);
@@ -14640,15 +14776,15 @@ Press Q to quit.`);
14640
14776
  return;
14641
14777
  }
14642
14778
  if (key === "l" || key === "\x1B[C" || key === "\x1BOC") {
14643
- const currentIdx = TABS.findIndex((t) => t.id === currentTab);
14644
- currentTab = TABS[(currentIdx + 1) % TABS.length].id;
14779
+ const currentIdx = visibleTabs.findIndex((t) => t.id === currentTab);
14780
+ currentTab = visibleTabs[(currentIdx + 1) % visibleTabs.length].id;
14645
14781
  vimState.scrollPos = 0;
14646
14782
  render2(false);
14647
14783
  return;
14648
14784
  }
14649
14785
  if (key === "h" || key === "\x1B[D" || key === "\x1BOD") {
14650
- const currentIdx = TABS.findIndex((t) => t.id === currentTab);
14651
- currentTab = TABS[(currentIdx - 1 + TABS.length) % TABS.length].id;
14786
+ const currentIdx = visibleTabs.findIndex((t) => t.id === currentTab);
14787
+ currentTab = visibleTabs[(currentIdx - 1 + visibleTabs.length) % visibleTabs.length].id;
14652
14788
  vimState.scrollPos = 0;
14653
14789
  render2(false);
14654
14790
  return;
@@ -14727,7 +14863,7 @@ Press Q to quit.`);
14727
14863
  render2(false);
14728
14864
  return;
14729
14865
  }
14730
- const tab = TABS.find((t) => t.key === key);
14866
+ const tab = visibleTabs.find((t) => t.key === key);
14731
14867
  if (tab) {
14732
14868
  currentTab = tab.id;
14733
14869
  vimState.scrollPos = 0;
@@ -14750,7 +14886,6 @@ var init_app = __esm(() => {
14750
14886
  init_state();
14751
14887
  init_constants();
14752
14888
  init_render();
14753
- init_daemon_client();
14754
14889
  });
14755
14890
 
14756
14891
  // src/tui/usage/index.ts
@@ -14792,7 +14927,7 @@ import { join as join3 } from "path";
14792
14927
  // src/integrations/opencode.ts
14793
14928
  init_logger();
14794
14929
  init_daemon_client();
14795
- import { existsSync as existsSync4, mkdirSync } from "fs";
14930
+ import { existsSync as existsSync3, mkdirSync } from "fs";
14796
14931
  import { readFile as readFile2, writeFile as writeFile2 } from "fs/promises";
14797
14932
  import { dirname as dirname2 } from "path";
14798
14933
  var OPENCODE_SMALL_MODEL = "routstr/minimax-m2.5";
@@ -14804,7 +14939,7 @@ Installing routstr models in opencode.json...`);
14804
14939
  const baseUrl = getDaemonBaseUrl(config);
14805
14940
  let opencodeConfig;
14806
14941
  try {
14807
- if (existsSync4(configPath)) {
14942
+ if (existsSync3(configPath)) {
14808
14943
  const content = await readFile2(configPath, "utf-8");
14809
14944
  opencodeConfig = JSON.parse(content);
14810
14945
  } else {
@@ -14849,7 +14984,7 @@ Installing routstr models in opencode.json...`);
14849
14984
  // src/integrations/pi.ts
14850
14985
  init_logger();
14851
14986
  init_daemon_client();
14852
- import { existsSync as existsSync5, mkdirSync as mkdirSync2 } from "fs";
14987
+ import { existsSync as existsSync4, mkdirSync as mkdirSync2 } from "fs";
14853
14988
  import { readFile as readFile3, writeFile as writeFile3 } from "fs/promises";
14854
14989
  import { dirname as dirname3 } from "path";
14855
14990
  async function installPiIntegration(config, apiKey, integrationConfig) {
@@ -14860,7 +14995,7 @@ Installing routstr models in pi models.json...`);
14860
14995
  const baseUrl = `${getDaemonBaseUrl(config)}/v1`;
14861
14996
  let piConfig = {};
14862
14997
  try {
14863
- if (existsSync5(configPath)) {
14998
+ if (existsSync4(configPath)) {
14864
14999
  const content = await readFile3(configPath, "utf-8");
14865
15000
  piConfig = JSON.parse(content);
14866
15001
  }
@@ -14897,7 +15032,7 @@ Installing routstr models in pi models.json...`);
14897
15032
  // src/integrations/openclaw.ts
14898
15033
  init_logger();
14899
15034
  init_daemon_client();
14900
- import { existsSync as existsSync6, mkdirSync as mkdirSync3 } from "fs";
15035
+ import { existsSync as existsSync5, mkdirSync as mkdirSync3 } from "fs";
14901
15036
  import { readFile as readFile4, writeFile as writeFile4 } from "fs/promises";
14902
15037
  import { dirname as dirname4 } from "path";
14903
15038
  var OPENCLAW_PROVIDER_ID = "routstr";
@@ -14911,7 +15046,7 @@ Installing routstr models in openclaw.json...`);
14911
15046
  const baseUrl = getDaemonBaseUrl(config);
14912
15047
  let openclawConfig = {};
14913
15048
  try {
14914
- if (existsSync6(configPath)) {
15049
+ if (existsSync5(configPath)) {
14915
15050
  const content = await readFile4(configPath, "utf-8");
14916
15051
  openclawConfig = JSON.parse(content);
14917
15052
  }
@@ -14973,7 +15108,7 @@ Installing routstr models in openclaw.json...`);
14973
15108
  // src/integrations/claudecode.ts
14974
15109
  init_logger();
14975
15110
  init_daemon_client();
14976
- import { existsSync as existsSync7, mkdirSync as mkdirSync4 } from "fs";
15111
+ import { existsSync as existsSync6, mkdirSync as mkdirSync4 } from "fs";
14977
15112
  import { readFile as readFile5, writeFile as writeFile5 } from "fs/promises";
14978
15113
  import { dirname as dirname5 } from "path";
14979
15114
  async function installClaudeCodeIntegration(config, apiKey, integrationConfig) {
@@ -14984,7 +15119,7 @@ Installing routstr configuration in ${configPath}...`);
14984
15119
  const baseUrl = getDaemonBaseUrl(config);
14985
15120
  let settings = {};
14986
15121
  try {
14987
- if (existsSync7(configPath)) {
15122
+ if (existsSync6(configPath)) {
14988
15123
  const content = await readFile5(configPath, "utf-8");
14989
15124
  settings = JSON.parse(content);
14990
15125
  }
@@ -15031,7 +15166,7 @@ Installing routstr configuration in ${configPath}...`);
15031
15166
  // src/integrations/hermes.ts
15032
15167
  init_logger();
15033
15168
  init_daemon_client();
15034
- import { existsSync as existsSync8, mkdirSync as mkdirSync5 } from "fs";
15169
+ import { existsSync as existsSync7, mkdirSync as mkdirSync5 } from "fs";
15035
15170
  import { readFile as readFile6, writeFile as writeFile6 } from "fs/promises";
15036
15171
  import { dirname as dirname6 } from "path";
15037
15172
  async function installHermesIntegration(config, apiKey, integrationConfig) {
@@ -15060,7 +15195,7 @@ Installing routstr configuration in ${configPath}...`);
15060
15195
  }
15061
15196
  let content = "";
15062
15197
  try {
15063
- if (existsSync8(configPath)) {
15198
+ if (existsSync7(configPath)) {
15064
15199
  content = await readFile6(configPath, "utf-8");
15065
15200
  }
15066
15201
  } catch (error) {
@@ -15312,7 +15447,7 @@ async function addClientAction(options) {
15312
15447
  // src/cli.ts
15313
15448
  init_config();
15314
15449
  init_logger();
15315
- import { existsSync as existsSync10, mkdirSync as mkdirSync6 } from "fs";
15450
+ import { existsSync as existsSync9, mkdirSync as mkdirSync6 } from "fs";
15316
15451
  import { execSync } from "child_process";
15317
15452
 
15318
15453
  // src/integrations/index.ts
@@ -15466,7 +15601,7 @@ init_nip98();
15466
15601
  init_esm();
15467
15602
 
15468
15603
  // src/daemon/wallet/cocod-client.ts
15469
- import { existsSync as existsSync9 } from "fs";
15604
+ import { existsSync as existsSync8 } from "fs";
15470
15605
  init_logger();
15471
15606
  init_process_lock();
15472
15607
  var DEFAULT_CONFIG_DIR = process.env.COCOD_DIR || `${process.env.HOME || process.env.USERPROFILE || ""}/.cocod`;
@@ -15478,7 +15613,7 @@ function resolveCocodExecutable(cocodPath) {
15478
15613
  async function isCocodInstalled(cocodPath) {
15479
15614
  const executable = resolveCocodExecutable(cocodPath);
15480
15615
  if (executable.includes("/")) {
15481
- return existsSync9(executable);
15616
+ return existsSync8(executable);
15482
15617
  }
15483
15618
  try {
15484
15619
  const proc = Bun.spawn({
@@ -15494,7 +15629,7 @@ async function isCocodInstalled(cocodPath) {
15494
15629
  // package.json
15495
15630
  var package_default = {
15496
15631
  name: "routstrd",
15497
- version: "0.2.17",
15632
+ version: "0.2.18",
15498
15633
  module: "src/index.ts",
15499
15634
  type: "module",
15500
15635
  private: false,
@@ -15518,7 +15653,7 @@ var package_default = {
15518
15653
  },
15519
15654
  dependencies: {
15520
15655
  "@cashu/cashu-ts": "^3.1.1",
15521
- "@routstr/sdk": "^0.3.3",
15656
+ "@routstr/sdk": "^0.3.4",
15522
15657
  "applesauce-core": "^5.1.0",
15523
15658
  "applesauce-relay": "^5.1.0",
15524
15659
  commander: "^14.0.2",
@@ -15573,11 +15708,11 @@ async function requireLocalDaemon() {
15573
15708
  }
15574
15709
  async function initDaemon() {
15575
15710
  logger.log("Initializing routstrd...");
15576
- if (!existsSync10(CONFIG_DIR)) {
15711
+ if (!existsSync9(CONFIG_DIR)) {
15577
15712
  mkdirSync6(CONFIG_DIR, { recursive: true });
15578
15713
  logger.log(`Created config directory: ${CONFIG_DIR}`);
15579
15714
  }
15580
- if (!existsSync10(CONFIG_FILE)) {
15715
+ if (!existsSync9(CONFIG_FILE)) {
15581
15716
  const config2 = {
15582
15717
  ...DEFAULT_CONFIG,
15583
15718
  cocodPath: null
@@ -15701,7 +15836,7 @@ program.command("remote <url>").description("Configure a remote daemon URL").act
15701
15836
  console.error(`Invalid URL: ${url}`);
15702
15837
  process.exit(1);
15703
15838
  }
15704
- if (!existsSync10(CONFIG_DIR)) {
15839
+ if (!existsSync9(CONFIG_DIR)) {
15705
15840
  mkdirSync6(CONFIG_DIR, { recursive: true });
15706
15841
  }
15707
15842
  const config = await loadConfig();
@@ -16296,7 +16431,7 @@ serviceCmd.command("install").description("Install and start routstrd using PM2
16296
16431
  const path = import.meta.require("path");
16297
16432
  daemonPath = path.join(path.dirname(import.meta.url).replace("file://", ""), "daemon", "index.js");
16298
16433
  }
16299
- if (!existsSync10(daemonPath)) {
16434
+ if (!existsSync9(daemonPath)) {
16300
16435
  console.error(`Could not find daemon at ${daemonPath}. Did you run 'bun run build'?`);
16301
16436
  process.exit(1);
16302
16437
  }
@@ -16434,14 +16569,14 @@ program.command("logs").description("View daemon logs").option("-f, --follow", "
16434
16569
  const yesterday = new Date;
16435
16570
  yesterday.setDate(yesterday.getDate() - 1);
16436
16571
  const yesterdayFile = getLogFileForDate2(yesterday);
16437
- if (!existsSync10(todayFile) && !existsSync10(yesterdayFile)) {
16572
+ if (!existsSync9(todayFile) && !existsSync9(yesterdayFile)) {
16438
16573
  console.log("No log files found. Daemon may not have started yet.");
16439
16574
  console.log(`Logs directory: ${LOGS_DIR2}`);
16440
16575
  process.exit(1);
16441
16576
  }
16442
16577
  const lines = parseInt(options.lines, 10);
16443
16578
  const logFiles = [yesterdayFile, todayFile].filter((file, index, files) => {
16444
- return existsSync10(file) && files.indexOf(file) === index;
16579
+ return existsSync9(file) && files.indexOf(file) === index;
16445
16580
  });
16446
16581
  if (options.follow) {
16447
16582
  const proc2 = Bun.spawn(["tail", "-n", String(lines), "-f", todayFile], {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "routstrd",
3
- "version": "0.2.17",
3
+ "version": "0.2.18",
4
4
  "module": "src/index.ts",
5
5
  "type": "module",
6
6
  "private": false,
@@ -24,7 +24,7 @@
24
24
  },
25
25
  "dependencies": {
26
26
  "@cashu/cashu-ts": "^3.1.1",
27
- "@routstr/sdk": "^0.3.3",
27
+ "@routstr/sdk": "^0.3.4",
28
28
  "applesauce-core": "^5.1.0",
29
29
  "applesauce-relay": "^5.1.0",
30
30
  "commander": "^14.0.2",
@@ -1,18 +1,9 @@
1
1
  import { logger } from "./utils/logger";
2
- import { existsSync } from "fs";
3
2
  import { CONFIG_DIR, LOGS_DIR } from "./utils/config";
4
3
  import { withCrossProcessLock } from "./utils/process-lock";
5
4
 
6
5
  const DAEMON_STARTUP_LOCK_PATH = `${CONFIG_DIR}/routstrd-startup.lock`;
7
6
 
8
- function getTodayLogFile(): string {
9
- const now = new Date();
10
- const year = now.getFullYear();
11
- const month = String(now.getMonth() + 1).padStart(2, "0");
12
- const day = String(now.getDate()).padStart(2, "0");
13
- return `${LOGS_DIR}/${year}-${month}-${day}.log`;
14
- }
15
-
16
7
  async function isDaemonHealthy(port: string): Promise<boolean> {
17
8
  const controller = new AbortController();
18
9
  const timeoutId = setTimeout(() => controller.abort(), 2000);
@@ -48,14 +39,8 @@ async function startDaemonUnlocked(
48
39
  args.push("--provider", options.provider);
49
40
  }
50
41
 
51
- // Ensure logs directory exists (logger handles date-based files)
52
- if (!existsSync(LOGS_DIR)) {
53
- await Bun.$`mkdir -p ${LOGS_DIR}`;
54
- }
55
-
56
42
  const daemonScript = new URL("./daemon/index.js", import.meta.url).pathname;
57
- const todayLogFile = getTodayLogFile();
58
- const shellCmd = `bun run "${daemonScript}" ${args.map((a) => `'${a}'`).join(" ")} >> "${todayLogFile}" 2>&1`;
43
+ const shellCmd = `bun run "${daemonScript}" ${args.map((a) => `'${a}'`).join(" ")}`;
59
44
 
60
45
  const proc = Bun.spawn(["sh", "-c", shellCmd], {
61
46
  stdout: "inherit",
@@ -1,5 +1,6 @@
1
- import { TABS } from "./constants.ts";
2
- import { fetchBalance, fetchStatus, fetchUsage, type BalanceInfo, type StatusInfo } from "./data.ts";
1
+ import { getVisibleTabs } from "./constants.ts";
2
+ import type { Tab } from "./types.ts";
3
+ import { fetchBalance, fetchClients, fetchStatus, fetchUsage, hasAnyNpubs, isDaemonRunning, type BalanceInfo, type ClientInfo, type StatusInfo } from "./data.ts";
3
4
  import {
4
5
  applyScrollToContent,
5
6
  exitSearchMode,
@@ -28,7 +29,6 @@ import {
28
29
  import { COLORS } from "./constants.ts";
29
30
  import { renderHeader, renderSearchBar, renderSeparator, renderTabContent, renderTabs } from "./render.ts";
30
31
  import type { TabId, UsageStats } from "./types.ts";
31
- import { isDaemonRunning } from "../../utils/daemon-client.ts";
32
32
 
33
33
  export async function runUsageTui(): Promise<void> {
34
34
  const running = await isDaemonRunning();
@@ -46,6 +46,8 @@ export async function runUsageTui(): Promise<void> {
46
46
  let stats: UsageStats | null = null;
47
47
  let balance: BalanceInfo | null = null;
48
48
  let status: StatusInfo | null = null;
49
+ let clients: ClientInfo[] = [];
50
+ let visibleTabs: Tab[] = getVisibleTabs(false);
49
51
  let refreshInterval: ReturnType<typeof setInterval> | null = null;
50
52
  let shouldUpdate = true;
51
53
  let autoRefresh = true;
@@ -90,6 +92,14 @@ export async function runUsageTui(): Promise<void> {
90
92
  stats = await fetchUsage(10000);
91
93
  balance = await fetchBalance();
92
94
  status = await fetchStatus();
95
+ clients = await fetchClients();
96
+ const npubsVisible = hasAnyNpubs(clients);
97
+ visibleTabs = getVisibleTabs(npubsVisible);
98
+ // If current tab is npubs but it's no longer visible, fall back to clients
99
+ if (currentTab === "npubs" && !npubsVisible) {
100
+ currentTab = "clients";
101
+ vimState.scrollPos = 0;
102
+ }
93
103
  shouldUpdate = false;
94
104
  }
95
105
 
@@ -104,9 +114,9 @@ export async function runUsageTui(): Promise<void> {
104
114
  return;
105
115
  }
106
116
 
107
- const content = renderTabContent(currentTab, stats, balance, status, width);
117
+ const content = renderTabContent(currentTab, stats, balance, status, width, clients);
108
118
  const footer = `${COLORS.dim}Press [Q] to quit, [R] to refresh, [A] to toggle auto-refresh${autoRefresh ? " (on)" : " (off)"} scroll:${vimState.scrollPos}${COLORS.reset}${vimState.mode === "normal" ? ` ${COLORS.yellow}vim: hjkl/arrows, / search, g top, gg bottom${COLORS.reset}` : ""}`;
109
- const chrome = renderHeader(currentTab, width) + renderTabs(currentTab) + renderSeparator(width) + renderSearchBar();
119
+ const chrome = renderHeader(currentTab, width, visibleTabs) + renderTabs(currentTab, visibleTabs) + renderSeparator(width) + renderSearchBar();
110
120
  const chromeLines = chrome.split("\n").length - 1;
111
121
  const footerSeparator = renderSeparator(width);
112
122
  const footerLines = footerSeparator.split("\n").length - 1;
@@ -176,15 +186,15 @@ export async function runUsageTui(): Promise<void> {
176
186
  return;
177
187
  }
178
188
  if (key === "l" || key === "\x1b[C" || key === "\x1bOC") {
179
- const currentIdx = TABS.findIndex((t) => t.id === currentTab);
180
- currentTab = TABS[(currentIdx + 1) % TABS.length]!.id;
189
+ const currentIdx = visibleTabs.findIndex((t) => t.id === currentTab);
190
+ currentTab = visibleTabs[(currentIdx + 1) % visibleTabs.length]!.id;
181
191
  vimState.scrollPos = 0;
182
192
  void render(false);
183
193
  return;
184
194
  }
185
195
  if (key === "h" || key === "\x1b[D" || key === "\x1bOD") {
186
- const currentIdx = TABS.findIndex((t) => t.id === currentTab);
187
- currentTab = TABS[(currentIdx - 1 + TABS.length) % TABS.length]!.id;
196
+ const currentIdx = visibleTabs.findIndex((t) => t.id === currentTab);
197
+ currentTab = visibleTabs[(currentIdx - 1 + visibleTabs.length) % visibleTabs.length]!.id;
188
198
  vimState.scrollPos = 0;
189
199
  void render(false);
190
200
  return;
@@ -226,7 +236,7 @@ export async function runUsageTui(): Promise<void> {
226
236
  }
227
237
  if (key === "\x1b") { scrollToTop(); void render(false); return; }
228
238
 
229
- const tab = TABS.find((t) => t.key === key);
239
+ const tab = visibleTabs.find((t) => t.key === key);
230
240
  if (tab) {
231
241
  currentTab = tab.id;
232
242
  vimState.scrollPos = 0;
@@ -1,15 +1,28 @@
1
1
  import type { Tab } from "./types.ts";
2
2
 
3
- export const TABS: Tab[] = [
3
+ export const ALL_TABS: Tab[] = [
4
4
  { id: "overview", name: "Overview", key: "1" },
5
5
  { id: "today", name: "Today", key: "2" },
6
6
  { id: "models", name: "Models", key: "3" },
7
7
  { id: "providers", name: "Providers", key: "4" },
8
8
  { id: "tokens", name: "Tokens", key: "5" },
9
9
  { id: "clients", name: "Clients", key: "6" },
10
- { id: "recent", name: "Recent", key: "7" },
10
+ { id: "npubs", name: "Npubs", key: "7" },
11
+ { id: "recent", name: "Recent", key: "8" },
11
12
  ];
12
13
 
14
+ /**
15
+ * Returns the visible tab list, hiding the Npubs tab when no clients
16
+ * have an ownerNpub. Keys are re-assigned sequentially (1..N).
17
+ */
18
+ export function getVisibleTabs(hasNpubs: boolean): Tab[] {
19
+ const tabs = ALL_TABS.filter((t) => t.id !== "npubs" || hasNpubs);
20
+ return tabs.map((t, i) => ({ ...t, key: String(i + 1) }));
21
+ }
22
+
23
+ /** Default tab list (no npub data yet). */
24
+ export const TABS: Tab[] = getVisibleTabs(false);
25
+
13
26
  export const COLORS = {
14
27
  reset: "\x1b[0m",
15
28
  bold: "\x1b[1m",
@@ -1,6 +1,8 @@
1
1
  import type { UsageTrackingEntry } from "../../daemon/types.ts";
2
2
  import { callDaemon, isDaemonRunning } from "../../utils/daemon-client.ts";
3
- import type { ClientStats, DayStats, ModelStats, ProviderStats, UsageStats } from "./types.ts";
3
+ import type { ClientStats, DayStats, ModelStats, NpubStats, ProviderStats, UsageStats } from "./types.ts";
4
+
5
+ export { isDaemonRunning };
4
6
 
5
7
  export interface BalanceKey {
6
8
  id: string;
@@ -223,6 +225,78 @@ export function getClientStats(entries: UsageTrackingEntry[]): ClientStats[] {
223
225
  return Array.from(clients.values()).sort((a, b) => b.satsCost - a.satsCost);
224
226
  }
225
227
 
228
+ // ─── Client / Npub helpers ────────────────────────────────────────────
229
+
230
+ export interface ClientInfo {
231
+ clientId: string;
232
+ name: string;
233
+ ownerNpub?: string;
234
+ }
235
+
236
+ export async function fetchClients(): Promise<ClientInfo[]> {
237
+ try {
238
+ const running = await isDaemonRunning();
239
+ if (!running) return [];
240
+
241
+ const result = await callDaemon("/clients");
242
+ if (result.error) return [];
243
+
244
+ const output = result.output as {
245
+ clients?: Array<{ id: string; name: string; ownerNpub?: string }>;
246
+ };
247
+
248
+ return (output?.clients || []).map((c) => ({
249
+ clientId: c.id,
250
+ name: c.name,
251
+ ownerNpub: c.ownerNpub,
252
+ }));
253
+ } catch {
254
+ return [];
255
+ }
256
+ }
257
+
258
+ export function hasAnyNpubs(clients: ClientInfo[]): boolean {
259
+ return clients.some((c) => !!c.ownerNpub);
260
+ }
261
+
262
+ export function getNpubStats(entries: UsageTrackingEntry[], clients: ClientInfo[]): NpubStats[] {
263
+ // Build client-id → ownerNpub lookup
264
+ const clientNpubMap = new Map<string, string>();
265
+ for (const c of clients) {
266
+ if (c.ownerNpub) {
267
+ clientNpubMap.set(c.clientId, c.ownerNpub);
268
+ }
269
+ }
270
+
271
+ // Aggregate usage per npub
272
+ const npubs = new Map<string, NpubStats>();
273
+ for (const entry of entries) {
274
+ const npub = clientNpubMap.get(entry.client || "");
275
+ if (!npub) continue; // skip entries whose client has no ownerNpub
276
+
277
+ const existing = npubs.get(npub) || {
278
+ npub,
279
+ requests: 0,
280
+ satsCost: 0,
281
+ promptTokens: 0,
282
+ completionTokens: 0,
283
+ totalTokens: 0,
284
+ };
285
+ npubs.set(npub, {
286
+ ...existing,
287
+ requests: existing.requests + 1,
288
+ satsCost: existing.satsCost + entry.satsCost,
289
+ promptTokens: existing.promptTokens + entry.promptTokens,
290
+ completionTokens: existing.completionTokens + entry.completionTokens,
291
+ totalTokens: existing.totalTokens + entry.totalTokens,
292
+ });
293
+ }
294
+
295
+ return Array.from(npubs.values()).sort((a, b) => b.satsCost - a.satsCost);
296
+ }
297
+
298
+ // ─── Totals ───────────────────────────────────────────────────────────
299
+
226
300
  export function getTotals(entries: UsageTrackingEntry[] | undefined) {
227
301
  if (!entries || !Array.isArray(entries)) {
228
302
  return { promptTokens: 0, completionTokens: 0, totalTokens: 0, satsCost: 0 };
@@ -1,4 +1,5 @@
1
- import { CLIENT_COLORS, COLORS, MODEL_COLORS, TABS } from "./constants.ts";
1
+ import { CLIENT_COLORS, COLORS, MODEL_COLORS } from "./constants.ts";
2
+ import type { Tab } from "./types.ts";
2
3
  import {
3
4
  formatDate,
4
5
  formatNumber,
@@ -7,9 +8,11 @@ import {
7
8
  getDayStats,
8
9
  getHourlyToday,
9
10
  getModelStats,
11
+ getNpubStats,
10
12
  getProviderStats,
11
13
  getTodayStart,
12
14
  getTotals,
15
+ type ClientInfo,
13
16
  } from "./data.ts";
14
17
  import { vimState } from "./state.ts";
15
18
  import { stripAnsi } from "./terminal.ts";
@@ -30,10 +33,11 @@ function formatReqs(value: number): string {
30
33
  return value.toString();
31
34
  }
32
35
 
33
- export function renderHeader(activeTab: TabId, width: number): string {
36
+ export function renderHeader(activeTab: TabId, width: number, visibleTabs: Tab[]): string {
34
37
  const title = `${COLORS.bold}${COLORS.cyan}ROUTSTRD USAGE MONITOR${COLORS.reset}`;
35
38
  const vimIndicator = `${COLORS.yellow}[vim]${COLORS.reset}`;
36
- const help = `${COLORS.dim}[Q] Quit [↑↓] Scroll [←→] Tabs [1-7] Tabs [R] Refresh${COLORS.reset}`;
39
+ const maxKey = visibleTabs.length;
40
+ const help = `${COLORS.dim}[Q] Quit [↑↓] Scroll [←→] Tabs [1-${maxKey}] Tabs [R] Refresh${COLORS.reset}`;
37
41
  const fill = width - title.length - help.length - vimIndicator.length - 6;
38
42
  return `${title}${vimIndicator}${" ".repeat(Math.max(1, fill))}${help}\n`;
39
43
  }
@@ -49,8 +53,8 @@ export function renderSearchBar(): string {
49
53
  return `\n${searchLine}${placeholder}\n`;
50
54
  }
51
55
 
52
- export function renderTabs(activeTab: TabId): string {
53
- const tabStr = TABS.map((tab) => tab.id === activeTab
56
+ export function renderTabs(activeTab: TabId, visibleTabs: Tab[]): string {
57
+ const tabStr = visibleTabs.map((tab) => tab.id === activeTab
54
58
  ? `${COLORS.bgBlue} ${tab.key}:${tab.name} ${COLORS.reset}`
55
59
  : `${COLORS.dim}[${tab.key}]${COLORS.reset} ${tab.name}`).join(" ");
56
60
  return `${" ".repeat(2)}${tabStr}\n`;
@@ -507,31 +511,124 @@ export function renderClients(stats: UsageStats, width: number): string {
507
511
  return output;
508
512
  }
509
513
 
514
+ export function renderNpubs(stats: UsageStats, clients: ClientInfo[], width: number): string {
515
+ const npubStats = getNpubStats(stats.entries, clients);
516
+ if (npubStats.length === 0) return renderBox(["No npub data available"], width, "Npub Breakdown");
517
+
518
+ const totalCost = stats.totalSatsCost;
519
+ const maxCost = npubStats[0]!.satsCost;
520
+ const lines: string[] = [];
521
+
522
+ const col1 = 24; // Npub (truncated)
523
+ const col2 = 12; // Requests
524
+ const col3 = 24; // Cost
525
+ const col4 = 12; // Tokens
526
+
527
+ const hNpub = "Npub".padEnd(col1);
528
+ const hReqs = "Requests".padEnd(col2);
529
+ const hCost = "Cost".padEnd(col3);
530
+ const hTok = "Tokens".padEnd(col4);
531
+ lines.push(`${COLORS.bold}${hNpub}${hReqs}${hCost}${hTok}Avg Cost${COLORS.reset}`);
532
+ lines.push(COLORS.dim + "─".repeat(Math.max(0, width - 4)) + COLORS.reset);
533
+
534
+ startBarSection("npub-detail", col1);
535
+ for (const npub of npubStats) {
536
+ const pct = totalCost > 0 ? ((npub.satsCost / totalCost) * 100).toFixed(1) : "0.0";
537
+ const avgCostFormatted = formatCost(npub.requests > 0 ? npub.satsCost / npub.requests : 0);
538
+ const shortNpub = truncateNpub(npub.npub);
539
+
540
+ const dNpub = shortNpub.padEnd(col1);
541
+ const dReqs = formatReqs(npub.requests).padEnd(col2);
542
+ const dCost = `${formatCost(npub.satsCost)} sats (${pct}%)`.padEnd(col3);
543
+ const dTok = formatNumber(npub.totalTokens).padEnd(col4);
544
+ const dAvg = `${avgCostFormatted} sats/req`;
545
+
546
+ lines.push(
547
+ `${COLORS.magenta}${COLORS.bold}${dNpub}${COLORS.reset}` +
548
+ `${dReqs}` +
549
+ `${COLORS.green}${dCost}${COLORS.reset}` +
550
+ `${COLORS.dim}${dTok}${dAvg}${COLORS.reset}`
551
+ );
552
+ // Full npub on its own line for copy-ability
553
+ lines.push(` ${COLORS.dim}${npub.npub}${COLORS.reset}`);
554
+ lines.push(` ${renderBarChart("", npub.satsCost, maxCost, width - 6, COLORS.magenta, Number(pct), "npub-detail")}`);
555
+ lines.push("");
556
+ }
557
+ endBarSection("npub-detail");
558
+
559
+ let output = renderBox(lines, width, "Npub Breakdown");
560
+
561
+ // Top models per npub (same pattern as clients tab)
562
+ const npubModelMap = new Map<string, Map<string, { requests: number; satsCost: number; tokens: number }>>();
563
+ const clientNpubMap = new Map<string, string>();
564
+ for (const c of clients) {
565
+ if (c.ownerNpub) clientNpubMap.set(c.clientId, c.ownerNpub);
566
+ }
567
+
568
+ for (const entry of stats.entries) {
569
+ const npub = clientNpubMap.get(entry.client || "");
570
+ if (!npub) continue;
571
+ const model = entry.modelId;
572
+ if (!npubModelMap.has(npub)) npubModelMap.set(npub, new Map());
573
+ const modelMap = npubModelMap.get(npub)!;
574
+ const existing = modelMap.get(model) || { requests: 0, satsCost: 0, tokens: 0 };
575
+ modelMap.set(model, {
576
+ requests: existing.requests + 1,
577
+ satsCost: existing.satsCost + entry.satsCost,
578
+ tokens: existing.tokens + entry.totalTokens,
579
+ });
580
+ }
581
+
582
+ const npubModelLines: string[] = [];
583
+ for (const topNpub of npubStats.slice(0, 5)) {
584
+ const modelMap = npubModelMap.get(topNpub.npub);
585
+ if (!modelMap) continue;
586
+ const models = Array.from(modelMap.entries()).sort((a, b) => b[1].satsCost - a[1].satsCost).slice(0, 5);
587
+ npubModelLines.push(`${COLORS.bold}${truncateNpub(topNpub.npub)}${COLORS.reset} (${formatReqs(topNpub.requests)} reqs, ${formatCost(topNpub.satsCost)} sats)`);
588
+ for (const [model, data] of models) {
589
+ npubModelLines.push(` ${(MODEL_COLORS[model] || MODEL_COLORS.default)}${model.padEnd(18)}${COLORS.reset} ${formatNumber(data.tokens).padEnd(8)} tokens ${formatCost(data.satsCost)} sats`);
590
+ }
591
+ npubModelLines.push("");
592
+ }
593
+
594
+ if (npubModelLines.length > 0) {
595
+ output += "\n" + renderBox(npubModelLines, width, "Top Models per Npub");
596
+ }
597
+
598
+ return output;
599
+ }
600
+
601
+ /** Truncate an npub for display: first 10 chars + … + last 6 chars. */
602
+ function truncateNpub(npub: string): string {
603
+ if (npub.length <= 24) return npub;
604
+ return npub.slice(0, 10) + "…" + npub.slice(-6);
605
+ }
606
+
510
607
  export function renderRecent(stats: UsageStats, width: number): string {
511
608
  const recentEntries = stats.entries.slice(0, 50);
512
609
  if (recentEntries.length === 0) return renderBox(["No recent entries"], width, "Recent Requests");
513
610
 
514
- const clientCol = 10;
611
+ const clientCol = 14;
515
612
  const lines: string[] = [];
516
- lines.push(`${COLORS.bold}${"TIME".padEnd(10)} ${"CLIENT".padEnd(clientCol)} ${"MODEL".padEnd(18)} ${"TOKENS".padEnd(10)} ${"COST".padEnd(12)} ${"PROVIDER".slice(0, Math.max(0, width - 70))}${COLORS.reset}`);
613
+ lines.push(`${COLORS.bold}${"TIME".padEnd(10)} ${"MODEL".padEnd(18)} ${"TOKENS".padEnd(10)} ${"COST".padEnd(12)} ${"PROVIDER".padEnd(16)} ${"CLIENT".slice(0, clientCol)}${COLORS.reset}`);
517
614
  lines.push(COLORS.dim + "─".repeat(width - 4) + COLORS.reset);
518
615
 
519
616
  for (const entry of recentEntries) {
520
617
  const time = formatTime(entry.timestamp).slice(0, 8);
521
- const clientName = (entry.client || "unknown").slice(0, clientCol - 1).padEnd(clientCol);
522
- const clientColor = CLIENT_COLORS[entry.client || "unknown"] || CLIENT_COLORS.default || COLORS.white;
523
618
  const model = entry.modelId.slice(0, 18).padEnd(18);
524
619
  const tokens = `${formatNumber(entry.totalTokens).padEnd(6)} (${formatNumber(entry.promptTokens)}+${formatNumber(entry.completionTokens)})`;
525
620
  const cost = `${formatCost(entry.satsCost).padEnd(8)} sats`;
526
- const provider = (entry.baseUrl || "unknown").replace("https://", "").replace("http://", "").slice(0, Math.max(0, width - 70));
621
+ const provider = (entry.baseUrl || "unknown").replace("https://", "").replace("http://", "").slice(0, 16).padEnd(16);
622
+ const clientName = (entry.client || "unknown").slice(0, clientCol - 1);
623
+ const clientColor = CLIENT_COLORS[entry.client || "unknown"] || CLIENT_COLORS.default || COLORS.white;
527
624
  const modelColor = MODEL_COLORS[entry.modelId] || MODEL_COLORS.default;
528
- lines.push(`${COLORS.dim}${time}${COLORS.reset} ${clientColor}${clientName}${COLORS.reset} ${modelColor}${model}${COLORS.reset} ${tokens.padEnd(10)} ${COLORS.green}${cost}${COLORS.reset} ${COLORS.dim}${provider}${COLORS.reset}`);
625
+ lines.push(`${COLORS.dim}${time}${COLORS.reset} ${modelColor}${model}${COLORS.reset} ${tokens.padEnd(10)} ${COLORS.green}${cost}${COLORS.reset} ${COLORS.dim}${provider}${COLORS.reset} ${clientColor}${clientName}${COLORS.reset}`);
529
626
  }
530
627
 
531
628
  return renderBox(lines, width, `Recent Requests (${stats.entries.length} shown)`);
532
629
  }
533
630
 
534
- export function renderTabContent(activeTab: TabId, stats: UsageStats, balance: BalanceInfo | null, status: StatusInfo | null, width: number): string {
631
+ export function renderTabContent(activeTab: TabId, stats: UsageStats, balance: BalanceInfo | null, status: StatusInfo | null, width: number, clients: ClientInfo[] = []): string {
535
632
  switch (activeTab) {
536
633
  case "overview": return renderOverview(stats, balance, status, width);
537
634
  case "today": return renderToday(stats, width);
@@ -539,6 +636,7 @@ export function renderTabContent(activeTab: TabId, stats: UsageStats, balance: B
539
636
  case "providers": return renderProviders(stats, width);
540
637
  case "tokens": return renderTokens(stats, width);
541
638
  case "clients": return renderClients(stats, width);
639
+ case "npubs": return renderNpubs(stats, clients, width);
542
640
  case "recent": return renderRecent(stats, width);
543
641
  default: return "Unknown tab";
544
642
  }
@@ -44,7 +44,7 @@ export interface ClientStats {
44
44
  totalTokens: number;
45
45
  }
46
46
 
47
- export type TabId = "overview" | "today" | "models" | "providers" | "tokens" | "clients" | "recent";
47
+ export type TabId = "overview" | "today" | "models" | "providers" | "tokens" | "clients" | "npubs" | "recent";
48
48
 
49
49
  export interface Tab {
50
50
  id: TabId;
@@ -52,6 +52,15 @@ export interface Tab {
52
52
  key: string;
53
53
  }
54
54
 
55
+ export interface NpubStats {
56
+ npub: string;
57
+ requests: number;
58
+ satsCost: number;
59
+ promptTokens: number;
60
+ completionTokens: number;
61
+ totalTokens: number;
62
+ }
63
+
55
64
  export interface VimState {
56
65
  scrollPos: number;
57
66
  searchQuery: string;