nomnomtokens 0.4.0 → 0.6.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 (120) hide show
  1. package/README.md +50 -27
  2. package/dist/index.js +1207 -129
  3. package/dist/index.js.map +1 -1
  4. package/package.json +1 -1
  5. package/web/nitro.json +1 -1
  6. package/web/public/_nuxt/AuWqCQTg.js +1 -0
  7. package/web/public/_nuxt/{W3KL_OjU.js → B1SHP0x7.js} +1 -1
  8. package/web/public/_nuxt/{DQEnwY-5.js → B4lg_KdO.js} +1 -1
  9. package/web/public/_nuxt/B8pNcdCn.js +1 -0
  10. package/web/public/_nuxt/{COwSpxrx.js → BL80uXr2.js} +1 -1
  11. package/web/public/_nuxt/{B2aHje2L.js → BOu13FsZ.js} +1 -1
  12. package/web/public/_nuxt/{CvIugsJe2.js → BYf03To42.js} +1 -1
  13. package/web/public/_nuxt/{CkRZsevx.js → B_fydnl_.js} +1 -1
  14. package/web/public/_nuxt/{DnTwjl0_2.js → BboXpc0v2.js} +2 -2
  15. package/web/public/_nuxt/{CfBMjq8x.js → Bg-w_mbM.js} +1 -1
  16. package/web/public/_nuxt/{B1jhn8zK.js → BsIJiDwC.js} +1 -1
  17. package/web/public/_nuxt/BsfWsX6E.js +1 -0
  18. package/web/public/_nuxt/{C9MiT_Wm.js → BuKW47N9.js} +2 -2
  19. package/web/public/_nuxt/{4C_a23zt.js → BvB3OU0z.js} +1 -1
  20. package/web/public/_nuxt/CLdT3CQk.js +1 -0
  21. package/web/public/_nuxt/{D9j6aFUy.js → Car0xtsn.js} +1 -1
  22. package/web/public/_nuxt/Ch1ysvEi.js +1 -0
  23. package/web/public/_nuxt/{BOKlFKrX.js → Ck5ddq-9.js} +1 -1
  24. package/web/public/_nuxt/{Cq1Sq3Go.js → CsVo4EXo.js} +1 -1
  25. package/web/public/_nuxt/CzaLG4_B2.js +1 -0
  26. package/web/public/_nuxt/{C51OzH6V.js → D9MZNmok.js} +1 -1
  27. package/web/public/_nuxt/{D9Mxq5ER.js → DQ6vgNw-.js} +1 -1
  28. package/web/public/_nuxt/{D49wVVwm2.js → DZ0oUjlX2.js} +1 -1
  29. package/web/public/_nuxt/DbTv-m36.js +1 -0
  30. package/web/public/_nuxt/{D4C6J8YV.js → DdHo_MGy.js} +1 -1
  31. package/web/public/_nuxt/{m3-Vaz25.js → DmteO8Ul.js} +1 -1
  32. package/web/public/_nuxt/DyoXCutE.js +1 -0
  33. package/web/public/_nuxt/{DPnm6zS2.js → DyvBBld_2.js} +1 -1
  34. package/web/public/_nuxt/{CIKwfDBl.js → PaV2v2OF.js} +1 -1
  35. package/web/public/_nuxt/{BMSLzLlT.js → RjmsMzM7.js} +1 -1
  36. package/web/public/_nuxt/builds/latest.json +1 -1
  37. package/web/public/_nuxt/builds/meta/fe9c123e-ca03-4d26-9bfd-e56ed456cf65.json +1 -0
  38. package/web/public/_nuxt/entry.CE8-9Sal.css +2 -0
  39. package/web/public/_nuxt/{Dy30RUCD2.js → hPGLTkZo2.js} +1 -1
  40. package/web/public/_nuxt/{Bprokln-.js → jeqgGzjf.js} +1 -1
  41. package/web/public/_nuxt/n29w62AT.js +1 -0
  42. package/web/public/_nuxt/{CDzUujek.js → tShBBc0S.js} +2 -2
  43. package/web/server/chunks/_/filters.mjs +1 -0
  44. package/web/server/chunks/_/filters.mjs.map +1 -1
  45. package/web/server/chunks/build/AuditCard-CgGiEb_r.mjs +290 -0
  46. package/web/server/chunks/build/AuditCard-CgGiEb_r.mjs.map +1 -0
  47. package/web/server/chunks/build/{CardContent-C_IMqI8K.mjs → CardDescription-Bi5t5iXj.mjs} +30 -30
  48. package/web/server/chunks/build/CardDescription-Bi5t5iXj.mjs.map +1 -0
  49. package/web/server/chunks/build/{FilterBar-BlEsJ5dy.mjs → FilterBar-BYBOdTrl.mjs} +2 -2
  50. package/web/server/chunks/build/{FilterBar-BlEsJ5dy.mjs.map → FilterBar-BYBOdTrl.mjs.map} +1 -1
  51. package/web/server/chunks/build/{Sparkline-CaCRq45J.mjs → Sparkline-B3kazZhU.mjs} +2 -2
  52. package/web/server/chunks/build/{Sparkline-CaCRq45J.mjs.map → Sparkline-B3kazZhU.mjs.map} +1 -1
  53. package/web/server/chunks/build/{_id_-BvccBwzH.mjs → _id_-DPUt6OzN.mjs} +4 -4
  54. package/web/server/chunks/build/{_id_-BvccBwzH.mjs.map → _id_-DPUt6OzN.mjs.map} +1 -1
  55. package/web/server/chunks/build/asyncData-CMa0z9Yw.mjs +2 -2
  56. package/web/server/chunks/build/audit-BPAlwhzn.mjs +1180 -0
  57. package/web/server/chunks/build/audit-BPAlwhzn.mjs.map +1 -0
  58. package/web/server/chunks/build/{default-BokiYQNv.mjs → default-CvQF0-O_.mjs} +46 -22
  59. package/web/server/chunks/build/default-CvQF0-O_.mjs.map +1 -0
  60. package/web/server/chunks/build/export-CwC19yOz.mjs +232 -0
  61. package/web/server/chunks/build/export-CwC19yOz.mjs.map +1 -0
  62. package/web/server/chunks/build/fetch-DJ9NNbFP.mjs +1 -1
  63. package/web/server/chunks/build/{import-CO3NEu0P.mjs → import-BmoVtcGR.mjs} +31 -15
  64. package/web/server/chunks/build/import-BmoVtcGR.mjs.map +1 -0
  65. package/web/server/chunks/build/{limits-CX9zBSqw.mjs → limits-C21_a18j.mjs} +5 -5
  66. package/web/server/chunks/build/{limits-CX9zBSqw.mjs.map → limits-C21_a18j.mjs.map} +1 -1
  67. package/web/server/chunks/build/nuxt-link-mYiLFsJs.mjs +2 -2
  68. package/web/server/chunks/build/{pages-DyXqJyPq.mjs → pages-CJpczmoA.mjs} +81 -11
  69. package/web/server/chunks/build/pages-CJpczmoA.mjs.map +1 -0
  70. package/web/server/chunks/build/{providers-Ca7keu2G.mjs → providers-BBVw9CDb.mjs} +4 -4
  71. package/web/server/chunks/build/{providers-Ca7keu2G.mjs.map → providers-BBVw9CDb.mjs.map} +1 -1
  72. package/web/server/chunks/build/{scopes-E5z80S19.mjs → scopes-DpYnOAM9.mjs} +203 -62
  73. package/web/server/chunks/build/scopes-DpYnOAM9.mjs.map +1 -0
  74. package/web/server/chunks/build/{sessions-CESRiCOS.mjs → sessions-B-Y_ifp1.mjs} +5 -5
  75. package/web/server/chunks/build/{sessions-CESRiCOS.mjs.map → sessions-B-Y_ifp1.mjs.map} +1 -1
  76. package/web/server/chunks/build/{timeline-d27OlbZN.mjs → timeline-8kK28WQB.mjs} +5 -5
  77. package/web/server/chunks/build/{timeline-d27OlbZN.mjs.map → timeline-8kK28WQB.mjs.map} +1 -1
  78. package/web/server/chunks/build/useFormat-NkyUqeik.mjs +1 -1
  79. package/web/server/chunks/build/{useStats-CmYzpdbE.mjs → useStats-BQQJIKQO.mjs} +7 -5
  80. package/web/server/chunks/build/{useStats-CmYzpdbE.mjs.map → useStats-BQQJIKQO.mjs.map} +1 -1
  81. package/web/server/chunks/nitro/nitro.mjs +695 -217
  82. package/web/server/chunks/nitro/nitro.mjs.map +1 -1
  83. package/web/server/chunks/routes/api/export.get.mjs +22 -3
  84. package/web/server/chunks/routes/api/export.get.mjs.map +1 -1
  85. package/web/server/chunks/routes/api/import/csv.post.mjs +20 -6
  86. package/web/server/chunks/routes/api/import/csv.post.mjs.map +1 -1
  87. package/web/server/chunks/routes/api/ingest.post.mjs +1 -1
  88. package/web/server/chunks/routes/api/meta.get.mjs +1 -1
  89. package/web/server/chunks/routes/api/scopes/_hash_.patch.mjs +35 -0
  90. package/web/server/chunks/routes/api/scopes/_hash_.patch.mjs.map +1 -0
  91. package/web/server/chunks/routes/api/stats/audit.get.mjs +60 -0
  92. package/web/server/chunks/routes/api/stats/audit.get.mjs.map +1 -0
  93. package/web/server/chunks/routes/api/stats/limits.get.mjs +1 -1
  94. package/web/server/chunks/routes/api/stats/providers.get.mjs +1 -1
  95. package/web/server/chunks/routes/api/stats/scopes.get.mjs +1 -1
  96. package/web/server/chunks/routes/api/stats/session/_id_.get.mjs +1 -1
  97. package/web/server/chunks/routes/api/stats/summary.get.mjs +1 -1
  98. package/web/server/chunks/routes/api/stats/verdict.get.mjs +35 -0
  99. package/web/server/chunks/routes/api/stats/verdict.get.mjs.map +1 -0
  100. package/web/server/chunks/routes/renderer.mjs +2 -2
  101. package/web/server/chunks/virtual/entry.mjs +17 -12
  102. package/web/server/chunks/virtual/precomputed.mjs +1 -1
  103. package/web/server/chunks/virtual/precomputed.mjs.map +1 -1
  104. package/web/server/index.mjs +1 -1
  105. package/web/public/_nuxt/Bia8fJFj.js +0 -1
  106. package/web/public/_nuxt/CIHIOSzn.js +0 -1
  107. package/web/public/_nuxt/CZSbzIi-.js +0 -1
  108. package/web/public/_nuxt/DMgad8mU.js +0 -1
  109. package/web/public/_nuxt/DhJW7zb9.js +0 -1
  110. package/web/public/_nuxt/OU1MVgXQ.js +0 -1
  111. package/web/public/_nuxt/builds/meta/b80e2306-16ec-4868-89f8-b713594d0c00.json +0 -1
  112. package/web/public/_nuxt/cbxnBwm6.js +0 -1
  113. package/web/public/_nuxt/entry.MbqNBIeH.css +0 -2
  114. package/web/server/chunks/build/CardContent-C_IMqI8K.mjs.map +0 -1
  115. package/web/server/chunks/build/default-BokiYQNv.mjs.map +0 -1
  116. package/web/server/chunks/build/export-BhfwmSMl.mjs +0 -184
  117. package/web/server/chunks/build/export-BhfwmSMl.mjs.map +0 -1
  118. package/web/server/chunks/build/import-CO3NEu0P.mjs.map +0 -1
  119. package/web/server/chunks/build/pages-DyXqJyPq.mjs.map +0 -1
  120. package/web/server/chunks/build/scopes-E5z80S19.mjs.map +0 -1
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { readFileSync, existsSync, mkdirSync, copyFileSync, writeFileSync, statSync, createReadStream, readdirSync } from 'fs';
2
+ import { readFileSync, existsSync, mkdirSync, copyFileSync, writeFileSync, statSync, readdirSync, createReadStream } from 'fs';
3
3
  import { dirname, join, resolve } from 'path';
4
4
  import { fileURLToPath } from 'url';
5
5
  import { Command } from 'commander';
@@ -4995,7 +4995,11 @@ var scopes = sqliteTable("scopes", {
4995
4995
  /** human-readable alias — local only, never synced */
4996
4996
  label: text("label").notNull(),
4997
4997
  provider: text("provider").notNull(),
4998
- lastSeen: integer("last_seen").notNull()
4998
+ lastSeen: integer("last_seen").notNull(),
4999
+ /** freelancer/client tag — local only, never attached to events */
5000
+ client: text("client"),
5001
+ /** 1 when the user renamed the label; ingest must not clobber it */
5002
+ labelLocked: integer("label_locked").notNull().default(0)
4999
5003
  });
5000
5004
  var limits = sqliteTable("limits", {
5001
5005
  ts: integer("ts").notNull(),
@@ -5015,7 +5019,7 @@ var scanState = sqliteTable("scan_state", {
5015
5019
  });
5016
5020
 
5017
5021
  // packages/db/src/client.ts
5018
- var SCHEMA_VERSION = 1;
5022
+ var SCHEMA_VERSION = 2;
5019
5023
  function defaultDataDir() {
5020
5024
  return process.env.NOMNOMTOKENS_HOME ?? join(homedir(), ".nomnomtokens");
5021
5025
  }
@@ -5045,7 +5049,9 @@ CREATE TABLE IF NOT EXISTS scopes (
5045
5049
  scope_hash TEXT PRIMARY KEY,
5046
5050
  label TEXT NOT NULL,
5047
5051
  provider TEXT NOT NULL,
5048
- last_seen INTEGER NOT NULL
5052
+ last_seen INTEGER NOT NULL,
5053
+ client TEXT,
5054
+ label_locked INTEGER NOT NULL DEFAULT 0
5049
5055
  );
5050
5056
 
5051
5057
  CREATE TABLE IF NOT EXISTS limits (
@@ -5065,6 +5071,16 @@ CREATE TABLE IF NOT EXISTS scan_state (
5065
5071
  offset INTEGER NOT NULL
5066
5072
  );
5067
5073
  `;
5074
+ function migrate(sqlite, from) {
5075
+ if (from < 2) {
5076
+ const cols = sqlite.pragma("table_info(scopes)");
5077
+ const names = new Set(cols.map((c2) => c2.name));
5078
+ if (!names.has("client")) sqlite.exec("ALTER TABLE scopes ADD COLUMN client TEXT");
5079
+ if (!names.has("label_locked")) {
5080
+ sqlite.exec("ALTER TABLE scopes ADD COLUMN label_locked INTEGER NOT NULL DEFAULT 0");
5081
+ }
5082
+ }
5083
+ }
5068
5084
  function openDb(path = defaultDbPath()) {
5069
5085
  if (path !== ":memory:") mkdirSync(dirname(path), { recursive: true });
5070
5086
  const sqlite = new Client(path);
@@ -5078,6 +5094,9 @@ function openDb(path = defaultDbPath()) {
5078
5094
  throw new Error(
5079
5095
  `Database at ${path} was written by a newer nomnomtokens (schema v${found}, this build understands v${SCHEMA_VERSION}). Upgrade the CLI.`
5080
5096
  );
5097
+ } else if (found < SCHEMA_VERSION) {
5098
+ migrate(sqlite, found);
5099
+ sqlite.pragma(`user_version = ${SCHEMA_VERSION}`);
5081
5100
  }
5082
5101
  const db = drizzle(sqlite, { schema: sqlite_exports });
5083
5102
  return { db, sqlite, path };
@@ -5229,6 +5248,12 @@ function bucketStart(ts, g) {
5229
5248
  return d.getTime();
5230
5249
  }
5231
5250
  }
5251
+ function cacheHitRate(qty) {
5252
+ const read = qty.cacheRead ?? 0;
5253
+ const fresh = qty.in ?? 0;
5254
+ const denom = read + fresh;
5255
+ return denom === 0 ? null : read / denom;
5256
+ }
5232
5257
 
5233
5258
  // packages/core/src/forecast.ts
5234
5259
  function linearRegression(points) {
@@ -5278,13 +5303,6 @@ function forecastLimit(snapshots, now = Date.now()) {
5278
5303
  samples: current.length
5279
5304
  };
5280
5305
  }
5281
- function moodFor(usedPct) {
5282
- if (usedPct < 5) return "hungry";
5283
- if (usedPct < 50) return "content";
5284
- if (usedPct < 80) return "full";
5285
- if (usedPct < 100) return "stuffed";
5286
- return "overstuffed";
5287
- }
5288
5306
 
5289
5307
  // packages/core/src/export-format.ts
5290
5308
  var CSV_HEADERS = [
@@ -5293,6 +5311,7 @@ var CSV_HEADERS = [
5293
5311
  "kind",
5294
5312
  "session_id",
5295
5313
  "project",
5314
+ "client",
5296
5315
  "scope_hash",
5297
5316
  "model",
5298
5317
  "cost_usd",
@@ -5318,6 +5337,7 @@ function formatExportCsv(rows) {
5318
5337
  csvCell(r.kind),
5319
5338
  csvCell(r.sessionId),
5320
5339
  csvCell(r.project),
5340
+ csvCell(r.client),
5321
5341
  csvCell(r.scopeHash),
5322
5342
  csvCell(r.model),
5323
5343
  csvCell(r.costUsd),
@@ -5340,6 +5360,7 @@ function formatExportJson(rows) {
5340
5360
  kind: r.kind,
5341
5361
  sessionId: r.sessionId,
5342
5362
  project: r.project,
5363
+ client: r.client,
5343
5364
  scopeHash: r.scopeHash,
5344
5365
  model: r.model,
5345
5366
  costUsd: r.costUsd,
@@ -5352,6 +5373,95 @@ function formatExportJson(rows) {
5352
5373
  })), null, 2)}
5353
5374
  `;
5354
5375
  }
5376
+ var NNT_ARCHIVE_VERSION = 1;
5377
+ function formatNntArchive(archive) {
5378
+ return `${JSON.stringify({ ...archive, version: NNT_ARCHIVE_VERSION }, null, 2)}
5379
+ `;
5380
+ }
5381
+ function numbersOnly(meta) {
5382
+ if (!meta || typeof meta !== "object") return {};
5383
+ const out = {};
5384
+ for (const [k, v] of Object.entries(meta)) {
5385
+ if (typeof v === "number" && Number.isFinite(v)) out[k] = v;
5386
+ }
5387
+ return out;
5388
+ }
5389
+ function qtyOnly(qty) {
5390
+ if (!qty || typeof qty !== "object") return {};
5391
+ const out = {};
5392
+ for (const [k, v] of Object.entries(qty)) {
5393
+ if (typeof v === "number" && Number.isFinite(v)) out[k] = v;
5394
+ }
5395
+ return out;
5396
+ }
5397
+ function looksLikeNntArchive(text2) {
5398
+ const trimmed = text2.trimStart();
5399
+ if (!trimmed.startsWith("{")) return false;
5400
+ try {
5401
+ const parsed = JSON.parse(text2);
5402
+ return parsed.version === NNT_ARCHIVE_VERSION && Array.isArray(parsed.events);
5403
+ } catch {
5404
+ return false;
5405
+ }
5406
+ }
5407
+ function parseNntArchive(text2) {
5408
+ let parsed;
5409
+ try {
5410
+ parsed = JSON.parse(text2);
5411
+ } catch {
5412
+ throw new Error("nnt archive is not valid JSON");
5413
+ }
5414
+ const data = parsed;
5415
+ if (data.version !== NNT_ARCHIVE_VERSION || !Array.isArray(data.events)) {
5416
+ throw new Error("not an nnt archive (expected { version: 1, events })");
5417
+ }
5418
+ const records = [];
5419
+ for (const raw of data.events) {
5420
+ if (!raw || typeof raw.id !== "string") continue;
5421
+ const qty = qtyOnly(raw.qty);
5422
+ const event = {
5423
+ id: raw.id,
5424
+ ts: typeof raw.ts === "number" ? raw.ts : 0,
5425
+ provider: typeof raw.provider === "string" ? raw.provider : "nnt",
5426
+ kind: typeof raw.kind === "string" ? raw.kind : "tokens",
5427
+ sessionId: typeof raw.sessionId === "string" ? raw.sessionId : null,
5428
+ scopeHash: typeof raw.scopeHash === "string" ? raw.scopeHash : "unknown",
5429
+ unitLabel: typeof raw.unitLabel === "string" ? raw.unitLabel : null,
5430
+ qty,
5431
+ costUsd: typeof raw.costUsd === "number" ? raw.costUsd : null,
5432
+ meta: numbersOnly(raw.meta)
5433
+ };
5434
+ if (!Number.isFinite(event.ts) || event.ts <= 0) continue;
5435
+ void totalTokens(qty);
5436
+ records.push({ type: "event", event });
5437
+ }
5438
+ for (const raw of data.limits ?? []) {
5439
+ if (!raw || typeof raw.ts !== "number" || typeof raw.provider !== "string") continue;
5440
+ records.push({
5441
+ type: "limit",
5442
+ limit: {
5443
+ ts: raw.ts,
5444
+ provider: raw.provider,
5445
+ window: typeof raw.window === "string" ? raw.window : "7d",
5446
+ usedPct: typeof raw.usedPct === "number" ? raw.usedPct : 0,
5447
+ resetsAt: typeof raw.resetsAt === "number" ? raw.resetsAt : null
5448
+ }
5449
+ });
5450
+ }
5451
+ for (const raw of data.scopes ?? []) {
5452
+ if (!raw || typeof raw.scopeHash !== "string" || typeof raw.label !== "string") continue;
5453
+ records.push({
5454
+ type: "scope",
5455
+ scopeHash: raw.scopeHash,
5456
+ label: raw.label,
5457
+ provider: typeof raw.provider === "string" ? raw.provider : "nnt",
5458
+ lastSeen: typeof raw.lastSeen === "number" ? raw.lastSeen : void 0,
5459
+ client: typeof raw.client === "string" ? raw.client : null,
5460
+ labelLocked: Boolean(raw.labelLocked)
5461
+ });
5462
+ }
5463
+ return records;
5464
+ }
5355
5465
 
5356
5466
  // packages/core/src/prices-file.ts
5357
5467
  function parsePricesFile(text2) {
@@ -5453,6 +5563,368 @@ function evaluateAlerts(config, input) {
5453
5563
  return hits;
5454
5564
  }
5455
5565
 
5566
+ // packages/core/src/audit.ts
5567
+ function share(part, whole) {
5568
+ if (whole <= 0) return null;
5569
+ return part / whole;
5570
+ }
5571
+ function isFrontierModel(label) {
5572
+ if (!label) return false;
5573
+ const l = label.toLowerCase();
5574
+ return l.includes("fable") || l.includes("opus");
5575
+ }
5576
+ function buildAuditReport(input) {
5577
+ const modelLimit = input.modelLimit ?? 5;
5578
+ const sessionLimit = input.sessionLimit ?? 5;
5579
+ const totalCost = input.totals.costUsd;
5580
+ const tagged = input.sidechain.taggedEvents > 0;
5581
+ const models = [...input.models].sort((a, b) => b.costUsd - a.costUsd).slice(0, modelLimit).map((m) => ({
5582
+ unitLabel: m.unitLabel,
5583
+ costUsd: m.costUsd,
5584
+ share: share(m.costUsd, totalCost) ?? 0
5585
+ }));
5586
+ const topSessions = input.sessions.slice(0, sessionLimit).map((s) => ({
5587
+ sessionId: s.sessionId,
5588
+ label: s.label,
5589
+ costUsd: s.costUsd,
5590
+ sidechainShare: s.sidechainTagged > 0 ? share(s.sidechainCostUsd, s.costUsd) : null
5591
+ }));
5592
+ const hitRate = cacheHitRate({
5593
+ cacheRead: input.totals.qtyCacheRead,
5594
+ in: input.totals.qtyIn
5595
+ });
5596
+ const sidechainShare = tagged ? share(input.sidechain.costUsd, totalCost) : null;
5597
+ const report = {
5598
+ range: input.range,
5599
+ totals: {
5600
+ costUsd: input.totals.costUsd,
5601
+ tokens: input.totals.tokens,
5602
+ events: input.totals.events
5603
+ },
5604
+ cache: {
5605
+ hitRate,
5606
+ readTokens: input.totals.qtyCacheRead,
5607
+ freshInTokens: input.totals.qtyIn
5608
+ },
5609
+ sidechain: {
5610
+ tagged,
5611
+ costUsd: input.sidechain.costUsd,
5612
+ tokens: input.sidechain.tokens,
5613
+ shareOfCost: sidechainShare
5614
+ },
5615
+ models,
5616
+ topSessions,
5617
+ coldResumes: (input.coldResumes ?? []).slice(0, sessionLimit),
5618
+ tips: []
5619
+ };
5620
+ report.tips = buildTips(report);
5621
+ return report;
5622
+ }
5623
+ function formatGap(ms) {
5624
+ const hours = Math.round(ms / 36e5);
5625
+ if (hours < 1) return `${Math.max(1, Math.round(ms / 6e4))}m`;
5626
+ return `${hours}h`;
5627
+ }
5628
+ function buildTips(report) {
5629
+ const tips = [];
5630
+ const cold = report.coldResumes[0];
5631
+ if (cold) {
5632
+ const name = cold.label ?? cold.sessionId.slice(0, 12);
5633
+ const dollars = cold.costUsd < 0.01 ? `$${cold.costUsd.toFixed(4)}` : `$${cold.costUsd.toFixed(2)}`;
5634
+ tips.push(
5635
+ `Session ${name} went cold after ${formatGap(cold.gapMs)}; the next turn cost ${dollars} in cache writes \u2014 /clear is cheaper than resume.`
5636
+ );
5637
+ }
5638
+ if (report.sidechain.tagged && report.sidechain.shareOfCost !== null && report.sidechain.shareOfCost >= 0.3) {
5639
+ const pct2 = Math.round(report.sidechain.shareOfCost * 100);
5640
+ tips.push(
5641
+ `Subagents are ${pct2}% of cost in this range \u2014 set CLAUDE_CODE_SUBAGENT_MODEL to sonnet or haiku so Task-tool workers stop inheriting the frontier model.`
5642
+ );
5643
+ }
5644
+ if (report.cache.hitRate !== null && report.cache.hitRate >= 0.85 && report.cache.readTokens > report.cache.freshInTokens) {
5645
+ tips.push(
5646
+ `Cache reads are ${Math.round(report.cache.hitRate * 100)}% of input tokens \u2014 they inflate the token count more than the dollar bill; watch cost, not raw tokens, when pacing limits.`
5647
+ );
5648
+ }
5649
+ const top = report.models[0];
5650
+ if (top && isFrontierModel(top.unitLabel) && top.share >= 0.5 && report.totals.costUsd > 0) {
5651
+ const name = top.unitLabel ?? "frontier";
5652
+ tips.push(
5653
+ `${name} is ${Math.round(top.share * 100)}% of spend \u2014 reserve it for hard tasks and default the rest to a cheaper model.`
5654
+ );
5655
+ }
5656
+ return tips.slice(0, 3);
5657
+ }
5658
+
5659
+ // packages/core/src/cold-resume.ts
5660
+ var COLD_GAP_MS = 60 * 60 * 1e3;
5661
+ var MIN_CACHE_WRITE = 2e3;
5662
+ function cacheWriteTokens(qty) {
5663
+ return (qty.cacheCreate ?? 0) + (qty.cacheCreate1h ?? 0);
5664
+ }
5665
+ function median(values) {
5666
+ if (values.length === 0) return 0;
5667
+ const sorted = [...values].sort((a, b) => a - b);
5668
+ const mid = Math.floor(sorted.length / 2);
5669
+ return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid];
5670
+ }
5671
+ function detectColdResumes(sessionId, label, turns) {
5672
+ if (turns.length < 2) return [];
5673
+ const sorted = [...turns].sort((a, b) => a.ts - b.ts);
5674
+ const writes = sorted.map((t) => cacheWriteTokens(t.qty));
5675
+ if (!writes.some((w) => w > 0)) return [];
5676
+ const medianWrite = median(writes.filter((w) => w > 0));
5677
+ const out = [];
5678
+ for (let i = 1; i < sorted.length; i++) {
5679
+ const prev = sorted[i - 1];
5680
+ const cur = sorted[i];
5681
+ const gapMs = cur.ts - prev.ts;
5682
+ if (gapMs < COLD_GAP_MS) continue;
5683
+ const write = cacheWriteTokens(cur.qty);
5684
+ if (write < MIN_CACHE_WRITE) continue;
5685
+ const inputish = write + (cur.qty.in ?? 0) + (cur.qty.cacheRead ?? 0);
5686
+ const writeShare = inputish > 0 ? write / inputish : 0;
5687
+ const aboveMedian = medianWrite > 0 ? write > medianWrite * 1.5 : true;
5688
+ if (!aboveMedian && writeShare < 0.5) continue;
5689
+ out.push({
5690
+ sessionId,
5691
+ label,
5692
+ ts: cur.ts,
5693
+ gapMs,
5694
+ costUsd: cur.costUsd ?? 0,
5695
+ cacheWriteTokens: write
5696
+ });
5697
+ }
5698
+ return out;
5699
+ }
5700
+ function collectColdResumes(sessions, limit = 5) {
5701
+ return sessions.flatMap((s) => detectColdResumes(s.sessionId, s.label, s.turns)).sort((a, b) => b.costUsd - a.costUsd).slice(0, limit);
5702
+ }
5703
+
5704
+ // packages/core/src/verdict.ts
5705
+ var PLAN_ESTIMATES = {
5706
+ asOf: "2026-09-02"};
5707
+ function latestPct(snapshots) {
5708
+ if (snapshots.length === 0) return null;
5709
+ const current = currentWindowSnapshots(snapshots);
5710
+ const last = current[current.length - 1];
5711
+ return last ? last.usedPct : null;
5712
+ }
5713
+ function elapsedWeekFraction(snapshots, now) {
5714
+ const current = currentWindowSnapshots(snapshots);
5715
+ const last = current[current.length - 1];
5716
+ if (!last) return null;
5717
+ const weekMs = 7 * 24 * 36e5;
5718
+ if (last.resetsAt && last.resetsAt > now) {
5719
+ const remaining = last.resetsAt - now;
5720
+ const elapsed = weekMs - remaining;
5721
+ if (elapsed <= 0) return 0.01;
5722
+ return Math.min(1, Math.max(0.01, elapsed / weekMs));
5723
+ }
5724
+ if (current.length < 2) return null;
5725
+ const span = last.ts - current[0].ts;
5726
+ return Math.min(1, Math.max(0.01, span / weekMs));
5727
+ }
5728
+ function buildVerdict(input) {
5729
+ const now = input.now ?? Date.now();
5730
+ const weeklyUsedPct = latestPct(input.weekly);
5731
+ const fiveHourUsedPct = latestPct(input.fiveHour);
5732
+ const fc7 = forecastLimit(input.weekly, now);
5733
+ const fc5 = forecastLimit(input.fiveHour, now);
5734
+ const caveats = [
5735
+ `Plan multipliers are estimates as of ${PLAN_ESTIMATES.asOf}, not Anthropic's bill.`,
5736
+ '"20x" multiplies the 5-hour window; weekly headroom grows much less (two Max 5x accounts often beat one 20x).',
5737
+ "Local used% is this machine only \u2014 other devices and claude.ai are invisible."
5738
+ ];
5739
+ if (weeklyUsedPct === null) {
5740
+ return {
5741
+ recommended: "unknown",
5742
+ weeklyBound: false,
5743
+ weeklyUsedPct: null,
5744
+ fiveHourUsedPct,
5745
+ weeklyCostUsd: input.weeklyCostUsd,
5746
+ two5xVs20x: {
5747
+ two5xWins: false,
5748
+ reason: "No 7-day limit snapshots yet \u2014 run `nnt init` so the status line records weekly fill."
5749
+ },
5750
+ estimatesAsOf: PLAN_ESTIMATES.asOf,
5751
+ caveats
5752
+ };
5753
+ }
5754
+ const weeklyBound = fiveHourUsedPct === null || weeklyUsedPct >= fiveHourUsedPct || Boolean(fc7?.willExhaustBeforeReset && !fc5?.willExhaustBeforeReset);
5755
+ const elapsed = elapsedWeekFraction(input.weekly, now);
5756
+ const pace = elapsed ? weeklyUsedPct / elapsed : weeklyUsedPct;
5757
+ let recommended;
5758
+ if (pace >= 180 || weeklyUsedPct >= 98) {
5759
+ recommended = "above-20x";
5760
+ } else if (weeklyBound && (weeklyUsedPct >= 85 || fc7?.willExhaustBeforeReset)) {
5761
+ recommended = weeklyUsedPct >= 92 ? "above-20x" : "max20x";
5762
+ } else if ((fiveHourUsedPct ?? 0) >= 90 || fc5?.willExhaustBeforeReset) {
5763
+ recommended = "max20x";
5764
+ } else if ((fiveHourUsedPct ?? 0) >= 50 || weeklyUsedPct >= 40) {
5765
+ recommended = "max5x";
5766
+ } else {
5767
+ recommended = "pro";
5768
+ }
5769
+ const two5xWins = weeklyBound;
5770
+ const two5xVs20x = {
5771
+ two5xWins,
5772
+ reason: two5xWins ? "The weekly cap is the wall. Two Max 5x accounts roughly double weekly pool; one 20x mostly buys a bigger 5-hour burst." : "The 5-hour window is tighter than weekly, so 20x's 5h multiplier is the lever \u2014 two 5x accounts would not help as much."
5773
+ };
5774
+ return {
5775
+ recommended,
5776
+ weeklyBound,
5777
+ weeklyUsedPct,
5778
+ fiveHourUsedPct,
5779
+ weeklyCostUsd: input.weeklyCostUsd,
5780
+ two5xVs20x,
5781
+ estimatesAsOf: PLAN_ESTIMATES.asOf,
5782
+ caveats
5783
+ };
5784
+ }
5785
+
5786
+ // packages/core/src/weigh.ts
5787
+ var WEIGH_CLEAN_SHARE = 0.8;
5788
+ var WEIGH_MIN_CLEAN = 3;
5789
+ var CAVEATS = [
5790
+ "Points per dollar use the list price stored now, not Anthropic's undocumented meter unit.",
5791
+ "A gap counts for a model only when that model is at least 80% of priced cost.",
5792
+ "Local used% is this machine only \u2014 other devices and claude.ai sit in the percent and not in the log."
5793
+ ];
5794
+ function quantile(sorted, p) {
5795
+ if (sorted.length === 1) return sorted[0];
5796
+ const idx = (sorted.length - 1) * p;
5797
+ const lo = Math.floor(idx);
5798
+ const hi = Math.ceil(idx);
5799
+ if (lo === hi) return sorted[lo];
5800
+ const a = sorted[lo];
5801
+ const b = sorted[hi];
5802
+ return a + (b - a) * (idx - lo);
5803
+ }
5804
+ function band(rates) {
5805
+ const sorted = [...rates].sort((a, b) => a - b);
5806
+ return {
5807
+ p25: quantile(sorted, 0.25),
5808
+ median: quantile(sorted, 0.5),
5809
+ p75: quantile(sorted, 0.75)
5810
+ };
5811
+ }
5812
+ function priced(models) {
5813
+ return models.filter((m) => m.costUsd > 0);
5814
+ }
5815
+ function pairRisingSnapshots(snapshots) {
5816
+ const current = currentWindowSnapshots(snapshots);
5817
+ const pairs = [];
5818
+ for (let i = 1; i < current.length; i++) {
5819
+ const prev = current[i - 1];
5820
+ const next = current[i];
5821
+ const deltaPct = next.usedPct - prev.usedPct;
5822
+ if (deltaPct <= 0) continue;
5823
+ pairs.push({ from: prev.ts, to: next.ts, deltaPct });
5824
+ }
5825
+ return pairs;
5826
+ }
5827
+ function buildWeigh(gaps) {
5828
+ let risingPct = 0;
5829
+ let risingUsd = 0;
5830
+ let risingIntervals = 0;
5831
+ let cleanIntervals = 0;
5832
+ const byLabel = /* @__PURE__ */ new Map();
5833
+ for (const gap of gaps) {
5834
+ if (gap.deltaPct <= 0) continue;
5835
+ const models2 = priced(gap.models);
5836
+ const total = models2.reduce((s, m) => s + m.costUsd, 0);
5837
+ if (total <= 0) continue;
5838
+ risingIntervals += 1;
5839
+ risingPct += gap.deltaPct;
5840
+ risingUsd += total;
5841
+ const rate = gap.deltaPct / total;
5842
+ const dominant = models2.reduce((a, b) => b.costUsd > a.costUsd ? b : a);
5843
+ if (dominant.costUsd + 1e-9 < total * WEIGH_CLEAN_SHARE) continue;
5844
+ cleanIntervals += 1;
5845
+ const key = dominant.unitLabel ?? "";
5846
+ const row = byLabel.get(key) ?? { label: dominant.unitLabel, rates: [] };
5847
+ row.rates.push(rate);
5848
+ byLabel.set(key, row);
5849
+ }
5850
+ const models = [...byLabel.values()].map((row) => {
5851
+ const ok = row.rates.length >= WEIGH_MIN_CLEAN;
5852
+ return {
5853
+ unitLabel: row.label,
5854
+ ppPerUsd: ok ? band(row.rates) : null,
5855
+ cleanIntervals: row.rates.length,
5856
+ status: ok ? "ok" : "unknown"
5857
+ };
5858
+ }).sort((a, b) => {
5859
+ if (a.status !== b.status) return a.status === "ok" ? -1 : 1;
5860
+ return (b.ppPerUsd?.median ?? 0) - (a.ppPerUsd?.median ?? 0);
5861
+ });
5862
+ return {
5863
+ window: "7d",
5864
+ provider: "claude-code",
5865
+ blendedPpPerUsd: risingUsd > 0 ? risingPct / risingUsd : null,
5866
+ risingIntervals,
5867
+ cleanIntervals,
5868
+ models,
5869
+ caveats: CAVEATS
5870
+ };
5871
+ }
5872
+
5873
+ // packages/core/src/otel.ts
5874
+ function attr(key, value) {
5875
+ return { key, value: { stringValue: value } };
5876
+ }
5877
+ function nano(ts) {
5878
+ return `${BigInt(Math.max(0, Math.round(ts))) * 1000000n}`;
5879
+ }
5880
+ function eventsToOtlp(events2, opts = {}) {
5881
+ const costPoints = [];
5882
+ const tokenPoints = [];
5883
+ for (const e of events2) {
5884
+ const attributes = [
5885
+ attr("provider", e.provider),
5886
+ attr("unit_label", e.unitLabel ?? "unknown"),
5887
+ attr("scope_hash", e.scopeHash)
5888
+ ];
5889
+ if (opts.includeLabels && e.label) attributes.push(attr("project", e.label));
5890
+ const t = nano(e.ts);
5891
+ if (e.costUsd !== null && Number.isFinite(e.costUsd)) {
5892
+ costPoints.push({ asDouble: e.costUsd, timeUnixNano: t, attributes });
5893
+ }
5894
+ tokenPoints.push({ asDouble: e.tokens, timeUnixNano: t, attributes });
5895
+ }
5896
+ return {
5897
+ resourceMetrics: [{
5898
+ resource: {
5899
+ attributes: [attr("service.name", opts.serviceName ?? "nomnomtokens")]
5900
+ },
5901
+ scopeMetrics: [{
5902
+ scope: { name: "nomnomtokens" },
5903
+ metrics: [
5904
+ {
5905
+ name: "nnt.cost_usd",
5906
+ unit: "USD",
5907
+ sum: {
5908
+ dataPoints: costPoints,
5909
+ aggregationTemporality: 2,
5910
+ isMonotonic: true
5911
+ }
5912
+ },
5913
+ {
5914
+ name: "nnt.tokens",
5915
+ unit: "1",
5916
+ sum: {
5917
+ dataPoints: tokenPoints,
5918
+ aggregationTemporality: 2,
5919
+ isMonotonic: true
5920
+ }
5921
+ }
5922
+ ]
5923
+ }]
5924
+ }]
5925
+ };
5926
+ }
5927
+
5456
5928
  // packages/db/src/repo.ts
5457
5929
  var Repo = class {
5458
5930
  constructor(sqlite) {
@@ -5474,11 +5946,13 @@ var Repo = class {
5474
5946
  WHERE excluded.qty_total >= events.qty_total
5475
5947
  `);
5476
5948
  this.upsertScope = sqlite.prepare(`
5477
- INSERT INTO scopes (scope_hash, label, provider, last_seen)
5478
- VALUES (@scopeHash, @label, @provider, @lastSeen)
5949
+ INSERT INTO scopes (scope_hash, label, provider, last_seen, client, label_locked)
5950
+ VALUES (@scopeHash, @label, @provider, @lastSeen, @client, @labelLocked)
5479
5951
  ON CONFLICT(scope_hash) DO UPDATE SET
5480
- label = excluded.label,
5481
- last_seen = MAX(scopes.last_seen, excluded.last_seen)
5952
+ label = CASE WHEN scopes.label_locked = 1 THEN scopes.label ELSE excluded.label END,
5953
+ last_seen = MAX(scopes.last_seen, excluded.last_seen),
5954
+ client = COALESCE(excluded.client, scopes.client),
5955
+ label_locked = MAX(scopes.label_locked, excluded.label_locked)
5482
5956
  `);
5483
5957
  this.insertLimit = sqlite.prepare(`
5484
5958
  INSERT INTO limits (ts, provider, window, used_pct, resets_at)
@@ -5537,7 +6011,9 @@ var Repo = class {
5537
6011
  scopeHash: rec.scopeHash,
5538
6012
  label: rec.label,
5539
6013
  provider: rec.provider,
5540
- lastSeen: Date.now()
6014
+ lastSeen: rec.lastSeen ?? Date.now(),
6015
+ client: rec.client ?? null,
6016
+ labelLocked: rec.labelLocked ? 1 : 0
5541
6017
  });
5542
6018
  result.scopes += 1;
5543
6019
  }
@@ -5569,6 +6045,24 @@ var Repo = class {
5569
6045
  eventCount() {
5570
6046
  return this.sqlite.prepare("SELECT COUNT(*) AS c FROM events").pluck().get();
5571
6047
  }
6048
+ /**
6049
+ * Rename a project or attach a client tag. Locks the label so a later scan
6050
+ * cannot revert it to the folder basename.
6051
+ */
6052
+ updateScope(scopeHash2, patch) {
6053
+ const row = this.sqlite.prepare(
6054
+ "SELECT label, client FROM scopes WHERE scope_hash = ?"
6055
+ ).get(scopeHash2);
6056
+ if (!row) return false;
6057
+ const label = patch.label !== void 0 ? patch.label.trim() || row.label : row.label;
6058
+ const client = patch.client !== void 0 ? patch.client?.trim() || null : row.client;
6059
+ this.sqlite.prepare(`
6060
+ UPDATE scopes
6061
+ SET label = @label, client = @client, label_locked = 1
6062
+ WHERE scope_hash = @scopeHash
6063
+ `).run({ scopeHash: scopeHash2, label, client });
6064
+ return true;
6065
+ }
5572
6066
  /**
5573
6067
  * Collapse Cursor Auto-mode placeholders (`default`, `default,default,…`)
5574
6068
  * into a null unit_label so the UI shows a single "unknown" bucket.
@@ -5630,6 +6124,12 @@ function buildWhere(f, prefix = "e") {
5630
6124
  });
5631
6125
  clauses.push(`${prefix}.scope_hash IN (${names.join(", ")})`);
5632
6126
  }
6127
+ if (f.client) {
6128
+ clauses.push(
6129
+ `${prefix}.scope_hash IN (SELECT scope_hash FROM scopes WHERE client = @client)`
6130
+ );
6131
+ params.client = f.client;
6132
+ }
5633
6133
  return { sql: clauses.length ? `WHERE ${clauses.join(" AND ")}` : "", params };
5634
6134
  }
5635
6135
  var BUCKET = {
@@ -5695,7 +6195,7 @@ var Queries = class {
5695
6195
  byScope(f = {}) {
5696
6196
  const w = buildWhere(f);
5697
6197
  return this.sqlite.prepare(`
5698
- SELECT e.scope_hash AS scopeHash, s.label AS label, s.last_seen AS lastSeen, ${TOTALS}
6198
+ SELECT e.scope_hash AS scopeHash, s.label AS label, s.client AS client, s.last_seen AS lastSeen, ${TOTALS}
5699
6199
  FROM events e
5700
6200
  LEFT JOIN scopes s ON s.scope_hash = e.scope_hash
5701
6201
  ${w.sql}
@@ -5784,11 +6284,89 @@ var Queries = class {
5784
6284
  limitWindows() {
5785
6285
  return this.sqlite.prepare("SELECT DISTINCT provider, window FROM limits ORDER BY provider, window").all();
5786
6286
  }
6287
+ /** Latest snapshot per (provider, window) — statusline Codex headroom. */
6288
+ latestLimits() {
6289
+ return this.sqlite.prepare(`
6290
+ SELECT l.ts, l.provider, l.window, l.used_pct AS usedPct, l.resets_at AS resetsAt
6291
+ FROM limits l
6292
+ INNER JOIN (
6293
+ SELECT provider, window, MAX(ts) AS ts
6294
+ FROM limits
6295
+ GROUP BY provider, window
6296
+ ) latest ON latest.provider = l.provider AND latest.window = l.window AND latest.ts = l.ts
6297
+ ORDER BY l.provider, l.window
6298
+ `).all();
6299
+ }
6300
+ coldResumesFor(sessions, limit = 5) {
6301
+ return collectColdResumes(
6302
+ sessions.map((s) => ({
6303
+ sessionId: s.sessionId,
6304
+ label: s.label,
6305
+ turns: this.sessionEvents(s.sessionId)
6306
+ })),
6307
+ limit
6308
+ );
6309
+ }
5787
6310
  scopeLabels() {
5788
6311
  return this.sqlite.prepare(`
5789
- SELECT scope_hash AS scopeHash, label, provider, last_seen AS lastSeen
6312
+ SELECT scope_hash AS scopeHash, label, provider, last_seen AS lastSeen,
6313
+ client, label_locked AS labelLocked
5790
6314
  FROM scopes ORDER BY last_seen DESC
5791
- `).all();
6315
+ `).all().map((row) => {
6316
+ const r = row;
6317
+ return { ...r, labelLocked: r.labelLocked === 1 };
6318
+ });
6319
+ }
6320
+ /**
6321
+ * Spend tagged as a Claude Code sidechain (subagent) turn.
6322
+ *
6323
+ * `taggedEvents` counts rows that have any `meta.sidechain` key — parents omit
6324
+ * it entirely, so zero tagged events means "this corpus has no sidechain
6325
+ * signal" (Cursor/Codex), not "0% subagents".
6326
+ */
6327
+ sidechainTotals(f = {}) {
6328
+ const w = buildWhere(f);
6329
+ const sidechainClause = `json_extract(e.meta_json, '$.sidechain') = 1`;
6330
+ const where = w.sql ? `${w.sql} AND ${sidechainClause}` : `WHERE ${sidechainClause}`;
6331
+ const totals = this.sqlite.prepare(`SELECT ${TOTALS} FROM events e ${where}`).get(w.params);
6332
+ const tagged = this.sqlite.prepare(`
6333
+ SELECT COUNT(*) AS n
6334
+ FROM events e ${w.sql}${w.sql ? " AND" : "WHERE"}
6335
+ json_extract(e.meta_json, '$.sidechain') IS NOT NULL
6336
+ `).get(w.params);
6337
+ return { ...totals, taggedEvents: tagged.n };
6338
+ }
6339
+ /**
6340
+ * Top sessions by cost, with the sidechain cost share when any turns are tagged.
6341
+ * Used by the audit report — not a general-purpose sessions list.
6342
+ */
6343
+ sessionsAudit(f = {}, limit = 5) {
6344
+ const w = buildWhere(f);
6345
+ const params = { ...w.params, limit };
6346
+ const rows = this.sqlite.prepare(`
6347
+ SELECT
6348
+ e.session_id AS sessionId,
6349
+ MIN(e.scope_hash) AS scopeHash,
6350
+ COALESCE(SUM(e.cost_usd), 0) AS costUsd,
6351
+ COALESCE(SUM(e.qty_total), 0) AS tokens,
6352
+ COALESCE(SUM(
6353
+ CASE WHEN json_extract(e.meta_json, '$.sidechain') = 1
6354
+ THEN e.cost_usd ELSE 0 END
6355
+ ), 0) AS sidechainCostUsd,
6356
+ SUM(
6357
+ CASE WHEN json_extract(e.meta_json, '$.sidechain') IS NOT NULL
6358
+ THEN 1 ELSE 0 END
6359
+ ) AS sidechainTagged
6360
+ FROM events e
6361
+ ${w.sql}${w.sql ? " AND" : "WHERE"} e.session_id IS NOT NULL
6362
+ GROUP BY e.session_id
6363
+ ORDER BY costUsd DESC
6364
+ LIMIT @limit
6365
+ `).all(params);
6366
+ return rows.map((r) => {
6367
+ const scope = this.sqlite.prepare("SELECT label FROM scopes WHERE scope_hash = ?").pluck().get(r.scopeHash);
6368
+ return { ...r, label: scope ?? null };
6369
+ });
5792
6370
  }
5793
6371
  /**
5794
6372
  * Lines the agent changed while earning the spend in `f`.
@@ -5827,6 +6405,7 @@ var Queries = class {
5827
6405
  e.session_id AS sessionId,
5828
6406
  e.scope_hash AS scopeHash,
5829
6407
  s.label AS project,
6408
+ s.client AS client,
5830
6409
  e.unit_label AS model,
5831
6410
  e.cost_usd AS costUsd,
5832
6411
  e.qty_total AS tokens,
@@ -5848,6 +6427,7 @@ var Queries = class {
5848
6427
  sessionId: r.sessionId,
5849
6428
  scopeHash: r.scopeHash,
5850
6429
  project: r.project,
6430
+ client: r.client,
5851
6431
  model: r.model,
5852
6432
  costUsd: r.costUsd,
5853
6433
  tokens: r.tokens,
@@ -5860,6 +6440,28 @@ var Queries = class {
5860
6440
  };
5861
6441
  });
5862
6442
  }
6443
+ exportSpendEvents(limit = 1e5) {
6444
+ return this.sqlite.prepare(`
6445
+ SELECT id, ts, provider, kind, session_id AS sessionId, scope_hash AS scopeHash,
6446
+ unit_label AS unitLabel, qty_json AS qtyJson, cost_usd AS costUsd,
6447
+ meta_json AS metaJson
6448
+ FROM events ORDER BY ts ASC LIMIT ?
6449
+ `).all(limit).map((row) => {
6450
+ const r = row;
6451
+ return {
6452
+ id: r.id,
6453
+ ts: r.ts,
6454
+ provider: r.provider,
6455
+ kind: r.kind,
6456
+ sessionId: r.sessionId,
6457
+ scopeHash: r.scopeHash,
6458
+ unitLabel: r.unitLabel,
6459
+ qty: JSON.parse(r.qtyJson),
6460
+ costUsd: r.costUsd,
6461
+ meta: JSON.parse(r.metaJson)
6462
+ };
6463
+ });
6464
+ }
5863
6465
  };
5864
6466
  function defaultPricesPath() {
5865
6467
  return join(defaultDataDir(), "prices.json");
@@ -5952,6 +6554,9 @@ function duration(ms) {
5952
6554
  if (ms < 1e3) return `${Math.round(ms)}ms`;
5953
6555
  return `${(ms / 1e3).toFixed(1)}s`;
5954
6556
  }
6557
+ function pct(n) {
6558
+ return n === null || n === void 0 ? "\u2014" : `${n.toFixed(1)}%`;
6559
+ }
5955
6560
  function untilReset(ms, now = Date.now()) {
5956
6561
  if (ms === null || ms === void 0 || !Number.isFinite(ms)) return null;
5957
6562
  const delta = ms - now;
@@ -6049,6 +6654,108 @@ function desktopNotify(hits) {
6049
6654
  }
6050
6655
  }
6051
6656
 
6657
+ // packages/cli/src/commands/audit.ts
6658
+ function rangeFromDays(days, now = Date.now()) {
6659
+ const to = now;
6660
+ const start = new Date(now);
6661
+ start.setHours(0, 0, 0, 0);
6662
+ start.setDate(start.getDate() - (days - 1));
6663
+ return { from: start.getTime(), to };
6664
+ }
6665
+ function formatGap2(ms) {
6666
+ const hours = Math.round(ms / 36e5);
6667
+ if (hours < 1) return `${Math.max(1, Math.round(ms / 6e4))}m`;
6668
+ return `${hours}h`;
6669
+ }
6670
+ function auditCommand(opts = {}) {
6671
+ const days = opts.days && opts.days > 0 ? opts.days : 7;
6672
+ const { from, to } = rangeFromDays(days);
6673
+ const { sqlite, path: dbPath } = openDb(opts.db);
6674
+ const q = new Queries(sqlite);
6675
+ const filters = { from, to, kind: "tokens" };
6676
+ const totals = q.totals(filters);
6677
+ const sidechain = q.sidechainTotals(filters);
6678
+ const models = q.byUnitLabel(filters);
6679
+ const sessions = q.sessionsAudit(filters, 5);
6680
+ const coldResumes = q.coldResumesFor(sessions);
6681
+ sqlite.close();
6682
+ const report = buildAuditReport({
6683
+ range: { from, to },
6684
+ totals: {
6685
+ costUsd: totals.costUsd,
6686
+ tokens: totals.tokens,
6687
+ events: totals.events,
6688
+ qtyIn: totals.qtyIn,
6689
+ qtyCacheRead: totals.qtyCacheRead
6690
+ },
6691
+ sidechain: {
6692
+ taggedEvents: sidechain.taggedEvents,
6693
+ costUsd: sidechain.costUsd,
6694
+ tokens: sidechain.tokens
6695
+ },
6696
+ models: models.map((m) => ({ unitLabel: m.unitLabel, costUsd: m.costUsd })),
6697
+ sessions: sessions.map((s) => ({
6698
+ sessionId: s.sessionId,
6699
+ label: s.label,
6700
+ costUsd: s.costUsd,
6701
+ sidechainCostUsd: s.sidechainCostUsd,
6702
+ sidechainTagged: s.sidechainTagged
6703
+ })),
6704
+ coldResumes
6705
+ });
6706
+ if (opts.json) {
6707
+ process.stdout.write(`${JSON.stringify(report, null, 2)}
6708
+ `);
6709
+ return;
6710
+ }
6711
+ console.log(c.bold(`Audit \xB7 last ${days}d`));
6712
+ console.log(c.dim(`from ${dbPath}`));
6713
+ console.log();
6714
+ console.log(` cost ${usd(report.totals.costUsd)}`);
6715
+ console.log(` tokens ${compactNumber(report.totals.tokens)}`);
6716
+ console.log(` events ${report.totals.events}`);
6717
+ console.log(` cache hit ${pct(report.cache.hitRate !== null ? report.cache.hitRate * 100 : null)}`);
6718
+ console.log(
6719
+ ` subagents ${report.sidechain.tagged ? `${pct(report.sidechain.shareOfCost !== null ? report.sidechain.shareOfCost * 100 : null)} \xB7 ${usd(report.sidechain.costUsd)}` : `${c.dim("n/a")} (Claude Code only)`}`
6720
+ );
6721
+ console.log();
6722
+ if (report.models.length) {
6723
+ console.log(c.bold("Models"));
6724
+ for (const m of report.models.slice(0, 5)) {
6725
+ const label = (m.unitLabel ?? "unknown").padEnd(28);
6726
+ console.log(` ${label} ${pct(m.share * 100).padStart(5)} ${usd(m.costUsd)}`);
6727
+ }
6728
+ console.log();
6729
+ }
6730
+ if (report.topSessions.length) {
6731
+ console.log(c.bold("Top sessions"));
6732
+ for (const s of report.topSessions) {
6733
+ const name = (s.label ?? s.sessionId.slice(0, 12)).padEnd(28);
6734
+ const sc = s.sidechainShare !== null ? ` \xB7 sub ${pct(s.sidechainShare * 100)}` : "";
6735
+ console.log(` ${name} ${usd(s.costUsd)}${sc}`);
6736
+ }
6737
+ console.log();
6738
+ }
6739
+ if (report.coldResumes.length) {
6740
+ console.log(c.bold("Cold resumes"));
6741
+ for (const hit of report.coldResumes) {
6742
+ const name = (hit.label ?? hit.sessionId.slice(0, 12)).padEnd(28);
6743
+ console.log(` ${name} ${usd(hit.costUsd)} after ${formatGap2(hit.gapMs)} idle`);
6744
+ }
6745
+ console.log();
6746
+ }
6747
+ if (report.tips.length) {
6748
+ console.log(c.bold("Tips"));
6749
+ for (const tip of report.tips) {
6750
+ console.log(` \u2022 ${tip}`);
6751
+ }
6752
+ console.log();
6753
+ }
6754
+ if (!opts.quiet && report.totals.events === 0) {
6755
+ console.log(c.dim("No token events in this range. Run `nnt scan`, then try again."));
6756
+ }
6757
+ }
6758
+
6052
6759
  // packages/adapters/src/claude-code/jsonl.ts
6053
6760
  function num(v) {
6054
6761
  return typeof v === "number" && Number.isFinite(v) ? v : 0;
@@ -7126,6 +7833,57 @@ async function detectAdapters(opts = {}) {
7126
7833
  }
7127
7834
 
7128
7835
  // packages/cli/src/commands/doctor.ts
7836
+ var DEFAULT_CLEANUP_DAYS = 30;
7837
+ function readCleanupPeriodDays(settingsPath2) {
7838
+ if (!existsSync(settingsPath2)) return DEFAULT_CLEANUP_DAYS;
7839
+ try {
7840
+ const parsed = JSON.parse(readFileSync(settingsPath2, "utf8"));
7841
+ const n = parsed.cleanupPeriodDays;
7842
+ return typeof n === "number" && Number.isFinite(n) && n > 0 ? n : DEFAULT_CLEANUP_DAYS;
7843
+ } catch {
7844
+ return DEFAULT_CLEANUP_DAYS;
7845
+ }
7846
+ }
7847
+ function listJsonlFiles(root) {
7848
+ if (!existsSync(root)) return [];
7849
+ const out = [];
7850
+ const stack = [root];
7851
+ while (stack.length > 0) {
7852
+ const dir = stack.pop();
7853
+ let entries;
7854
+ try {
7855
+ entries = readdirSync(dir, { withFileTypes: true });
7856
+ } catch {
7857
+ continue;
7858
+ }
7859
+ for (const entry of entries) {
7860
+ const path = join(dir, entry.name);
7861
+ if (entry.isDirectory()) stack.push(path);
7862
+ else if (entry.isFile() && entry.name.endsWith(".jsonl")) out.push(path);
7863
+ }
7864
+ }
7865
+ return out;
7866
+ }
7867
+ function oldestMtime(paths) {
7868
+ let min = null;
7869
+ for (const path of paths) {
7870
+ try {
7871
+ const t = statSync(path).mtimeMs;
7872
+ if (min === null || t < min) min = t;
7873
+ } catch {
7874
+ }
7875
+ }
7876
+ return min;
7877
+ }
7878
+ function transcriptRetentionWarning(opts) {
7879
+ if (opts.storeFirst === null) return null;
7880
+ const now = opts.now ?? Date.now();
7881
+ const cutoff = now - opts.cleanupDays * 864e5;
7882
+ const storeOlderThanRetention = opts.storeFirst < cutoff;
7883
+ const transcriptsAlreadyGone = opts.oldestJsonlMtime !== null && opts.oldestJsonlMtime > opts.storeFirst + 864e5;
7884
+ if (!storeOlderThanRetention && !transcriptsAlreadyGone) return null;
7885
+ return `store keeps history from ${new Date(opts.storeFirst).toISOString().slice(0, 10)}; transcripts will not (cleanupPeriodDays=${opts.cleanupDays})`;
7886
+ }
7129
7887
  function check(ok, label, detail) {
7130
7888
  const mark = ok ? c.green("\u2714") : c.yellow("\u2717");
7131
7889
  console.log(`${mark} ${label}${detail ? c.dim(` ${detail}`) : ""}`);
@@ -7134,7 +7892,7 @@ async function doctor(opts = {}) {
7134
7892
  console.log(c.bold("nomnomtokens doctor"));
7135
7893
  console.log();
7136
7894
  console.log(c.bold("Sources"));
7137
- const projects = claudeProjectsDir();
7895
+ const projects = opts.claudeProjects ?? claudeProjectsDir();
7138
7896
  check(existsSync(projects), "Claude Code transcripts", projects);
7139
7897
  const cursorDb = cursorStateDbPath();
7140
7898
  check(existsSync(cursorDb), "Cursor state database", cursorDb);
@@ -7144,7 +7902,7 @@ async function doctor(opts = {}) {
7144
7902
  check(adapters.length > 0, `${adapters.length} adapter(s) detected`, adapters.map((a) => a.name).join(", ") || "none");
7145
7903
  console.log();
7146
7904
  console.log(c.bold("Status line"));
7147
- const settingsFile = join(homedir(), ".claude", "settings.json");
7905
+ const settingsFile = opts.claudeSettings ?? join(homedir(), ".claude", "settings.json");
7148
7906
  let statusLineCommand;
7149
7907
  if (existsSync(settingsFile)) {
7150
7908
  try {
@@ -7176,6 +7934,14 @@ async function doctor(opts = {}) {
7176
7934
  check(false, `${totals.unpricedEvents} events could not be priced`, "unknown model id \u2014 see packages/core/src/pricing.ts");
7177
7935
  }
7178
7936
  check(windows.length > 0, "limit history", windows.length > 0 ? windows.map((w) => `${w.provider}/${w.window}`).join(", ") : "none yet \u2014 the status line hook has not fired");
7937
+ const cleanupDays = readCleanupPeriodDays(settingsFile);
7938
+ const jsonl = listJsonlFiles(projects);
7939
+ const warning = transcriptRetentionWarning({
7940
+ storeFirst: bounds.first,
7941
+ oldestJsonlMtime: oldestMtime(jsonl),
7942
+ cleanupDays
7943
+ });
7944
+ check(!warning, "transcript retention", warning ?? `cleanupPeriodDays ${cleanupDays}`);
7179
7945
  sqlite.close();
7180
7946
  }
7181
7947
  console.log();
@@ -7199,23 +7965,48 @@ function rangeFrom(range, now = Date.now()) {
7199
7965
  }
7200
7966
  }
7201
7967
  function exportCommand(opts = {}) {
7202
- const format = opts.format === "json" ? "json" : "csv";
7968
+ const format = opts.format === "json" || opts.format === "nnt" ? opts.format : "csv";
7203
7969
  const { sqlite, path: dbPath } = openDb(opts.db);
7204
7970
  const q = new Queries(sqlite);
7205
- const from = rangeFrom(opts.range ?? "30d");
7206
- const rows = q.exportEvents({
7207
- from,
7208
- kind: "tokens",
7209
- provider: opts.provider ? [opts.provider] : void 0
7210
- });
7971
+ let body;
7972
+ let count;
7973
+ if (format === "nnt") {
7974
+ const scopes2 = q.scopeLabels();
7975
+ body = formatNntArchive({
7976
+ version: 1,
7977
+ events: q.exportSpendEvents(),
7978
+ limits: q.limitSnapshots(),
7979
+ scopes: scopes2.map((s) => ({
7980
+ scopeHash: s.scopeHash,
7981
+ label: s.label,
7982
+ provider: s.provider,
7983
+ lastSeen: s.lastSeen,
7984
+ client: s.client,
7985
+ labelLocked: s.labelLocked
7986
+ }))
7987
+ });
7988
+ count = q.bounds().events;
7989
+ if (!opts.quiet) {
7990
+ console.error(c.dim("nnt archive includes local project labels. Treat the file as the whole store."));
7991
+ }
7992
+ } else {
7993
+ const from = rangeFrom(opts.range ?? "30d");
7994
+ const rows = q.exportEvents({
7995
+ from,
7996
+ kind: "tokens",
7997
+ provider: opts.provider ? [opts.provider] : void 0,
7998
+ client: opts.client
7999
+ });
8000
+ body = format === "json" ? formatExportJson(rows) : formatExportCsv(rows);
8001
+ count = rows.length;
8002
+ }
7211
8003
  sqlite.close();
7212
- const body = format === "json" ? formatExportJson(rows) : formatExportCsv(rows);
7213
8004
  if (opts.out) {
7214
8005
  const dest = resolve(opts.out);
7215
8006
  writeFileSync(dest, body, "utf8");
7216
8007
  if (!opts.quiet) {
7217
8008
  console.log(
7218
- `${c.cyan("export")} ${c.bold(String(rows.length))} events \u2192 ${dest}`
8009
+ `${c.cyan("export")} ${c.bold(String(count))} events \u2192 ${dest}`
7219
8010
  );
7220
8011
  console.log(c.dim(`from ${dbPath}`));
7221
8012
  }
@@ -7223,7 +8014,7 @@ function exportCommand(opts = {}) {
7223
8014
  }
7224
8015
  process.stdout.write(body);
7225
8016
  if (!opts.quiet && process.stdout.isTTY) {
7226
- console.error(c.dim(`${compactNumber(rows.length)} events \xB7 ${format}`));
8017
+ console.error(c.dim(`${compactNumber(count)} events \xB7 ${format}`));
7227
8018
  }
7228
8019
  }
7229
8020
  async function importCsvCommand(opts) {
@@ -7274,6 +8065,47 @@ async function importCsvCommand(opts) {
7274
8065
  console.log(c.dim(`${compactNumber(before + added)} events in ${dbPath}`));
7275
8066
  }
7276
8067
  }
8068
+
8069
+ // packages/cli/src/commands/import.ts
8070
+ async function importCommand(opts) {
8071
+ const path = resolve(opts.file);
8072
+ let text2;
8073
+ try {
8074
+ text2 = readFileSync(path, "utf8");
8075
+ } catch (err) {
8076
+ console.error(c.red(`Cannot read ${path}`));
8077
+ console.error(c.dim(err instanceof Error ? err.message : String(err)));
8078
+ process.exitCode = 1;
8079
+ return;
8080
+ }
8081
+ if (looksLikeNntArchive(text2)) {
8082
+ let records;
8083
+ try {
8084
+ records = parseNntArchive(text2);
8085
+ } catch (err) {
8086
+ console.error(c.red(err instanceof Error ? err.message : String(err)));
8087
+ process.exitCode = 1;
8088
+ return;
8089
+ }
8090
+ const { sqlite, path: dbPath } = openDb(opts.db);
8091
+ const repo = new Repo(sqlite);
8092
+ const before = repo.eventCount();
8093
+ const result = repo.ingest(records);
8094
+ const added = repo.eventCount() - before;
8095
+ sqlite.close();
8096
+ if (!opts.quiet) {
8097
+ console.log(
8098
+ `${c.cyan("nnt")} ${c.bold(String(added))} new ` + c.dim(
8099
+ `${result.events} events, ${result.duplicates} already known, ${result.limits} limits, ${result.scopes} scopes`
8100
+ )
8101
+ );
8102
+ console.log(c.dim(`${compactNumber(before + added)} events in ${dbPath}`));
8103
+ console.log(c.dim("Archive includes local project labels and client tags. scan_state is not imported."));
8104
+ }
8105
+ return;
8106
+ }
8107
+ await importCsvCommand(opts);
8108
+ }
7277
8109
  function settingsPath(override) {
7278
8110
  return override ?? join(homedir(), ".claude", "settings.json");
7279
8111
  }
@@ -7330,74 +8162,6 @@ function init(opts = {}) {
7330
8162
  console.log("Restart Claude Code, then run `nnt serve` to open the dashboard.");
7331
8163
  console.log(c.dim("Limits and live updates only start flowing once the hook has fired once."));
7332
8164
  }
7333
- async function loadFromSource(from) {
7334
- let text2;
7335
- if (/^https?:\/\//i.test(from)) {
7336
- const res = await fetch(from);
7337
- if (!res.ok) throw new Error(`fetch ${from}: HTTP ${res.status}`);
7338
- text2 = await res.text();
7339
- } else {
7340
- text2 = readFileSync(resolve(from), "utf8");
7341
- }
7342
- const file = parsePricesFile(text2);
7343
- return {
7344
- ...file,
7345
- updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
7346
- source: from
7347
- };
7348
- }
7349
- function pricesShow() {
7350
- const { file, path } = loadPriceTable();
7351
- const overrideCount = file ? Object.keys(file.models).length : 0;
7352
- console.log(c.bold("Price table"));
7353
- console.log(` file ${existsLabel(path, file !== null)}`);
7354
- if (file?.updatedAt) console.log(` updated ${file.updatedAt}`);
7355
- if (file?.source) console.log(` source ${file.source}`);
7356
- console.log(` bundled ${Object.keys(PRICES).length} models`);
7357
- console.log(` overrides ${overrideCount} model(s) in file`);
7358
- console.log();
7359
- console.log(c.dim("Sample (effective USD / 1M tokens):"));
7360
- for (const id of ["claude-sonnet-4-5", "claude-opus-4-6", "gpt-5.6", "gemini-3-flash"]) {
7361
- const key = resolveModelKey(id);
7362
- const p = key ? file?.models[key] ?? PRICES[key] : null;
7363
- console.log(` ${id.padEnd(22)} ${formatPrice(p)}`);
7364
- }
7365
- console.log();
7366
- console.log(c.dim("Edit the file, or run `nnt prices refresh` / `nnt prices refresh --from <url|path>`."));
7367
- }
7368
- function existsLabel(path, ok) {
7369
- return ok ? path : `${path} ${c.dim("(missing \u2014 using bundled)")}`;
7370
- }
7371
- function formatPrice(p) {
7372
- if (!p) return c.dim("unpriced");
7373
- return `in $${p.input} out $${p.output}`;
7374
- }
7375
- async function pricesRefresh(opts = {}) {
7376
- const path = defaultPricesPath();
7377
- let file;
7378
- if (opts.from) {
7379
- try {
7380
- file = await loadFromSource(opts.from);
7381
- } catch (err) {
7382
- console.error(c.red(err instanceof Error ? err.message : String(err)));
7383
- process.exitCode = 1;
7384
- return;
7385
- }
7386
- file = {
7387
- ...file,
7388
- models: { ...PRICES, ...file.models }
7389
- };
7390
- savePricesFile(file, path);
7391
- } else {
7392
- file = writeBundledPrices(path);
7393
- }
7394
- if (!opts.quiet) {
7395
- console.log(
7396
- `${c.cyan("prices")} wrote ${c.bold(String(Object.keys(file.models).length))} models \u2192 ${path}`
7397
- );
7398
- if (file.source) console.log(c.dim(`source ${file.source}`));
7399
- }
7400
- }
7401
8165
 
7402
8166
  // packages/cli/src/commands/scan.ts
7403
8167
  async function scan(opts = {}) {
@@ -7478,15 +8242,142 @@ async function scan(opts = {}) {
7478
8242
  if (opts.watch) {
7479
8243
  console.log(c.dim("\nWatching for changes. Ctrl-C to stop."));
7480
8244
  const stops = adapters.filter((a) => a.watch).map((a) => a.watch((rec) => repo.ingest([rec])));
7481
- await new Promise((resolve6) => {
8245
+ await new Promise((resolve7) => {
7482
8246
  process.on("SIGINT", () => {
7483
8247
  stops.forEach((stop) => stop());
7484
- resolve6();
8248
+ resolve7();
7485
8249
  });
7486
8250
  });
7487
8251
  }
7488
8252
  sqlite.close();
7489
8253
  }
8254
+
8255
+ // packages/cli/src/commands/otel.ts
8256
+ function otelEvents(q, includeLabels) {
8257
+ const labels = new Map(q.scopeLabels().map((s) => [s.scopeHash, s.label]));
8258
+ return q.exportSpendEvents().map((e) => {
8259
+ const row = {
8260
+ ts: e.ts,
8261
+ provider: e.provider,
8262
+ unitLabel: e.unitLabel,
8263
+ scopeHash: e.scopeHash,
8264
+ costUsd: e.costUsd,
8265
+ tokens: totalTokens(e.qty)
8266
+ };
8267
+ if (includeLabels) row.label = labels.get(e.scopeHash) ?? null;
8268
+ return row;
8269
+ });
8270
+ }
8271
+ async function postOtlp(endpoint, body) {
8272
+ const url = endpoint.replace(/\/$/, "");
8273
+ const target = url.endsWith("/v1/metrics") ? url : `${url}/v1/metrics`;
8274
+ const res = await fetch(target, {
8275
+ method: "POST",
8276
+ headers: { "content-type": "application/json" },
8277
+ body: JSON.stringify(body)
8278
+ });
8279
+ if (!res.ok) {
8280
+ const text2 = await res.text().catch(() => "");
8281
+ throw new Error(`OTLP POST ${res.status} ${text2.slice(0, 200)}`);
8282
+ }
8283
+ }
8284
+ async function otelCommand(opts) {
8285
+ const endpoint = opts.endpoint.trim();
8286
+ if (!endpoint) {
8287
+ console.error(c.red("nnt otel requires --endpoint"));
8288
+ process.exitCode = 1;
8289
+ return;
8290
+ }
8291
+ if (opts.scan || opts.watch) {
8292
+ await scan({ db: opts.db, quiet: true });
8293
+ }
8294
+ const { sqlite, path: dbPath } = openDb(opts.db);
8295
+ const q = new Queries(sqlite);
8296
+ const events2 = otelEvents(q, Boolean(opts.includeLabels));
8297
+ sqlite.close();
8298
+ const body = eventsToOtlp(events2, { includeLabels: opts.includeLabels });
8299
+ try {
8300
+ await postOtlp(endpoint, body);
8301
+ } catch (err) {
8302
+ console.error(c.red(err instanceof Error ? err.message : String(err)));
8303
+ process.exitCode = 1;
8304
+ return;
8305
+ }
8306
+ if (!opts.quiet) {
8307
+ console.log(
8308
+ `${c.cyan("otel")} ${c.bold(String(events2.length))} events \u2192 ${endpoint}`
8309
+ );
8310
+ console.log(c.dim(`from ${dbPath} \xB7 numbers and hashes only`));
8311
+ }
8312
+ }
8313
+ async function loadFromSource(from) {
8314
+ let text2;
8315
+ if (/^https?:\/\//i.test(from)) {
8316
+ const res = await fetch(from);
8317
+ if (!res.ok) throw new Error(`fetch ${from}: HTTP ${res.status}`);
8318
+ text2 = await res.text();
8319
+ } else {
8320
+ text2 = readFileSync(resolve(from), "utf8");
8321
+ }
8322
+ const file = parsePricesFile(text2);
8323
+ return {
8324
+ ...file,
8325
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
8326
+ source: from
8327
+ };
8328
+ }
8329
+ function pricesShow() {
8330
+ const { file, path } = loadPriceTable();
8331
+ const overrideCount = file ? Object.keys(file.models).length : 0;
8332
+ console.log(c.bold("Price table"));
8333
+ console.log(` file ${existsLabel(path, file !== null)}`);
8334
+ if (file?.updatedAt) console.log(` updated ${file.updatedAt}`);
8335
+ if (file?.source) console.log(` source ${file.source}`);
8336
+ console.log(` bundled ${Object.keys(PRICES).length} models`);
8337
+ console.log(` overrides ${overrideCount} model(s) in file`);
8338
+ console.log();
8339
+ console.log(c.dim("Sample (effective USD / 1M tokens):"));
8340
+ for (const id of ["claude-sonnet-4-5", "claude-opus-4-6", "gpt-5.6", "gemini-3-flash"]) {
8341
+ const key = resolveModelKey(id);
8342
+ const p = key ? file?.models[key] ?? PRICES[key] : null;
8343
+ console.log(` ${id.padEnd(22)} ${formatPrice(p)}`);
8344
+ }
8345
+ console.log();
8346
+ console.log(c.dim("Edit the file, or run `nnt prices refresh` / `nnt prices refresh --from <url|path>`."));
8347
+ }
8348
+ function existsLabel(path, ok) {
8349
+ return ok ? path : `${path} ${c.dim("(missing \u2014 using bundled)")}`;
8350
+ }
8351
+ function formatPrice(p) {
8352
+ if (!p) return c.dim("unpriced");
8353
+ return `in $${p.input} out $${p.output}`;
8354
+ }
8355
+ async function pricesRefresh(opts = {}) {
8356
+ const path = defaultPricesPath();
8357
+ let file;
8358
+ if (opts.from) {
8359
+ try {
8360
+ file = await loadFromSource(opts.from);
8361
+ } catch (err) {
8362
+ console.error(c.red(err instanceof Error ? err.message : String(err)));
8363
+ process.exitCode = 1;
8364
+ return;
8365
+ }
8366
+ file = {
8367
+ ...file,
8368
+ models: { ...PRICES, ...file.models }
8369
+ };
8370
+ savePricesFile(file, path);
8371
+ } else {
8372
+ file = writeBundledPrices(path);
8373
+ }
8374
+ if (!opts.quiet) {
8375
+ console.log(
8376
+ `${c.cyan("prices")} wrote ${c.bold(String(Object.keys(file.models).length))} models \u2192 ${path}`
8377
+ );
8378
+ if (file.source) console.log(c.dim(`source ${file.source}`));
8379
+ }
8380
+ }
7490
8381
  var here = dirname(fileURLToPath(import.meta.url));
7491
8382
  function findBuiltServer() {
7492
8383
  let dir = here;
@@ -7544,24 +8435,73 @@ async function serve(opts = {}) {
7544
8435
  });
7545
8436
  }
7546
8437
 
7547
- // packages/cli/src/commands/statusline.ts
7548
- var MOOD_FACE = {
7549
- hungry: "(\u30FB_\u30FB)",
7550
- content: "(^_^)",
7551
- full: "(\uFF3E\uFF55\uFF3E)",
7552
- stuffed: "(>_<)",
7553
- overstuffed: "(x_x)"
7554
- };
7555
- function limitSegment(label, usedPct, resetsAtSeconds) {
8438
+ // packages/cli/src/statusline-render.ts
8439
+ var CLAUDE_WEEKLY_HINT = 80;
8440
+ var CODEX_HEADROOM = 50;
8441
+ var ESC2 = "\x1B";
8442
+ function paintLabel(label) {
8443
+ if (process.env.NO_COLOR !== void 0) return label;
8444
+ return `${ESC2}[36m${label}${ESC2}[0m`;
8445
+ }
8446
+ function limitSegment(label, usedPct, resetsAtSeconds, now) {
7556
8447
  const resetsAt = typeof resetsAtSeconds === "number" && Number.isFinite(resetsAtSeconds) ? resetsAtSeconds * 1e3 : null;
7557
- const left = untilReset(resetsAt);
7558
- return `${label} ${Math.round(usedPct)}%${left ? ` \xB7${left}` : ""}`;
8448
+ const left = untilReset(resetsAt, now);
8449
+ const head = `${paintLabel(label)} ${Math.round(usedPct)}%`;
8450
+ return left ? `${head} ${left}` : head;
8451
+ }
8452
+ function resolveWindow(livePct, liveResetsAtSeconds, cached, now) {
8453
+ if (livePct !== null) {
8454
+ return { pct: livePct, resetsAtSeconds: liveResetsAtSeconds };
8455
+ }
8456
+ if (cached && cached.resetsAt !== null && cached.resetsAt > now) {
8457
+ return { pct: cached.usedPct, resetsAtSeconds: cached.resetsAt / 1e3 };
8458
+ }
8459
+ return { pct: null };
8460
+ }
8461
+ function buildStatuslineParts(view) {
8462
+ const now = view.now;
8463
+ const five = view.fiveHour;
8464
+ const seven = view.sevenDay;
8465
+ const segments = [];
8466
+ if (seven !== null) segments.push(limitSegment("7d", seven, view.sevenDayResetsAt, now));
8467
+ if (five !== null) segments.push(limitSegment("5h", five, view.fiveHourResetsAt, now));
8468
+ if (view.ctx !== null) segments.push(`${paintLabel("ctx")}. ${Math.round(view.ctx)}%`);
8469
+ const model = view.model?.trim();
8470
+ if (model) segments.push(paintLabel(model));
8471
+ const codex = view.codexSevenDay;
8472
+ if (seven !== null && seven >= CLAUDE_WEEKLY_HINT && typeof codex === "number" && Number.isFinite(codex) && codex <= CODEX_HEADROOM) {
8473
+ segments.push(`${paintLabel("codex")}: ${Math.round(codex)}%`);
8474
+ }
8475
+ return segments.length === 0 ? "Limits:" : `Limits: ${segments.join(" | ")}`;
7559
8476
  }
8477
+
8478
+ // packages/cli/src/commands/statusline.ts
8479
+ var CODEX_FRESH_MS = 24 * 36e5;
7560
8480
  async function readStdin() {
7561
8481
  const chunks = [];
7562
8482
  for await (const chunk of process.stdin) chunks.push(chunk);
7563
8483
  return Buffer.concat(chunks).toString("utf8");
7564
8484
  }
8485
+ function readStoredLimits(db, now = Date.now()) {
8486
+ const empty = { codexSevenDay: null, claudeFive: null, claudeSeven: null };
8487
+ try {
8488
+ const { sqlite } = openDb(db);
8489
+ const latest = new Queries(sqlite).latestLimits();
8490
+ sqlite.close();
8491
+ const claude = (window) => {
8492
+ const row = latest.find((l) => l.provider === "claude-code" && l.window === window);
8493
+ return row ? { usedPct: row.usedPct, resetsAt: row.resetsAt } : null;
8494
+ };
8495
+ const codex = latest.find((l) => l.provider === "codex" && l.window === "7d");
8496
+ return {
8497
+ codexSevenDay: codex && now - codex.ts <= CODEX_FRESH_MS ? codex.usedPct : null,
8498
+ claudeFive: claude("5h"),
8499
+ claudeSeven: claude("7d")
8500
+ };
8501
+ } catch {
8502
+ return empty;
8503
+ }
8504
+ }
7565
8505
  async function statusline(opts = {}) {
7566
8506
  let payload;
7567
8507
  try {
@@ -7571,19 +8511,33 @@ async function statusline(opts = {}) {
7571
8511
  return;
7572
8512
  }
7573
8513
  const limits2 = payload.rate_limits;
7574
- const fiveHour = limits2?.five_hour?.used_percentage ?? null;
7575
- const sevenDay = limits2?.seven_day?.used_percentage ?? null;
7576
- const cost = payload.cost?.total_cost_usd ?? null;
8514
+ const now = Date.now();
8515
+ const stored = readStoredLimits(opts.db, now);
8516
+ const five = resolveWindow(
8517
+ limits2?.five_hour?.used_percentage ?? null,
8518
+ limits2?.five_hour?.resets_at,
8519
+ stored.claudeFive,
8520
+ now
8521
+ );
8522
+ const seven = resolveWindow(
8523
+ limits2?.seven_day?.used_percentage ?? null,
8524
+ limits2?.seven_day?.resets_at,
8525
+ stored.claudeSeven,
8526
+ now
8527
+ );
7577
8528
  const ctx = payload.context_window?.used_percentage ?? null;
7578
- const parts = [];
7579
- parts.push(MOOD_FACE[moodFor(Math.max(fiveHour ?? 0, sevenDay ?? 0))] ?? "(^_^)");
7580
- if (cost !== null) parts.push(usd(cost));
7581
- if (ctx !== null) parts.push(`ctx ${Math.round(ctx)}%`);
7582
- if (fiveHour !== null) parts.push(limitSegment("5h", fiveHour, limits2?.five_hour?.resets_at));
7583
- if (sevenDay !== null) parts.push(limitSegment("7d", sevenDay, limits2?.seven_day?.resets_at));
7584
- const lines = (payload.cost?.total_lines_added ?? 0) + (payload.cost?.total_lines_removed ?? 0);
7585
- if (lines > 0) parts.push(`${compactNumber(lines)} lines`);
7586
- process.stdout.write(`${parts.join(" ")}
8529
+ const model = payload.model?.display_name?.trim() || payload.model?.id?.trim() || null;
8530
+ const line = buildStatuslineParts({
8531
+ fiveHour: five.pct,
8532
+ sevenDay: seven.pct,
8533
+ fiveHourResetsAt: five.resetsAtSeconds,
8534
+ sevenDayResetsAt: seven.resetsAtSeconds,
8535
+ ctx,
8536
+ model,
8537
+ codexSevenDay: stored.codexSevenDay,
8538
+ now
8539
+ });
8540
+ process.stdout.write(`${line}
7587
8541
  `);
7588
8542
  if (opts.dryRun) return;
7589
8543
  try {
@@ -7595,6 +8549,94 @@ async function statusline(opts = {}) {
7595
8549
  }
7596
8550
  }
7597
8551
 
8552
+ // packages/cli/src/commands/verdict.ts
8553
+ function verdictCommand(opts = {}) {
8554
+ const now = Date.now();
8555
+ const weekFrom = now - 7 * 24 * 36e5;
8556
+ const { sqlite, path: dbPath } = openDb(opts.db);
8557
+ const q = new Queries(sqlite);
8558
+ const weekly = q.limitSnapshots("claude-code", "7d", weekFrom);
8559
+ const fiveHour = q.limitSnapshots("claude-code", "5h", weekFrom);
8560
+ const weeklyCostUsd = q.totals({ from: weekFrom, to: now, kind: "tokens", provider: ["claude-code"] }).costUsd;
8561
+ sqlite.close();
8562
+ const report = buildVerdict({ weekly, fiveHour, weeklyCostUsd, now });
8563
+ if (opts.json) {
8564
+ process.stdout.write(`${JSON.stringify(report, null, 2)}
8565
+ `);
8566
+ return;
8567
+ }
8568
+ console.log(c.bold("Verdict"));
8569
+ console.log(c.dim(`from ${dbPath}`));
8570
+ console.log();
8571
+ console.log(` recommended ${c.bold(report.recommended)}`);
8572
+ console.log(` weekly bound ${report.weeklyBound ? "yes" : "no"}`);
8573
+ console.log(` 7d used ${pct(report.weeklyUsedPct)}`);
8574
+ console.log(` 5h used ${pct(report.fiveHourUsedPct)}`);
8575
+ console.log(` week $ ${usd(report.weeklyCostUsd)} ${c.dim("(list price, this machine)")}`);
8576
+ console.log();
8577
+ console.log(c.bold("Two Max 5x vs one 20x"));
8578
+ console.log(` ${report.two5xVs20x.two5xWins ? c.yellow("two 5x wins") : "20x wins the 5h burst"}`);
8579
+ console.log(` ${c.dim(report.two5xVs20x.reason)}`);
8580
+ console.log();
8581
+ console.log(c.bold("Caveats"));
8582
+ for (const line of report.caveats) {
8583
+ console.log(` \u2022 ${line}`);
8584
+ }
8585
+ }
8586
+
8587
+ // packages/cli/src/commands/weigh.ts
8588
+ function weighCommand(opts = {}) {
8589
+ const { sqlite, path: dbPath } = openDb(opts.db);
8590
+ const q = new Queries(sqlite);
8591
+ const snaps = q.limitSnapshots("claude-code", "7d");
8592
+ const gaps = pairRisingSnapshots(snaps).map((p) => ({
8593
+ deltaPct: p.deltaPct,
8594
+ models: q.byUnitLabel({
8595
+ from: p.from,
8596
+ to: p.to,
8597
+ provider: ["claude-code"],
8598
+ kind: "tokens"
8599
+ }).filter((row) => row.costUsd > 0).map((row) => ({ unitLabel: row.unitLabel, costUsd: row.costUsd }))
8600
+ }));
8601
+ sqlite.close();
8602
+ const report = buildWeigh(gaps);
8603
+ if (opts.json) {
8604
+ process.stdout.write(`${JSON.stringify(report, null, 2)}
8605
+ `);
8606
+ return;
8607
+ }
8608
+ printWeigh(report, dbPath);
8609
+ }
8610
+ function printWeigh(report, dbPath) {
8611
+ console.log(c.bold("Weigh"));
8612
+ console.log(c.dim(`from ${dbPath}`));
8613
+ console.log();
8614
+ console.log(` window ${report.provider} ${report.window}`);
8615
+ console.log(` blended ${pp(report.blendedPpPerUsd)} ${c.dim("per list-price dollar, all rising gaps")}`);
8616
+ console.log(` gaps ${report.cleanIntervals} clean / ${report.risingIntervals} rising`);
8617
+ console.log();
8618
+ console.log(c.bold("Models"));
8619
+ if (report.models.length === 0) {
8620
+ console.log(c.dim(" no priced gaps in the current 7d window \u2014 run `nnt init` so the status line records weekly fill"));
8621
+ }
8622
+ for (const model of report.models) console.log(modelLine(model));
8623
+ console.log();
8624
+ console.log(c.bold("Caveats"));
8625
+ for (const line of report.caveats) console.log(` \u2022 ${line}`);
8626
+ }
8627
+ function modelLine(model) {
8628
+ const name = model.unitLabel ?? "(unlabeled)";
8629
+ if (model.status === "unknown" || !model.ppPerUsd) {
8630
+ return ` ${name} ${c.yellow("unknown")} ${c.dim(`need 3 clean gaps, have ${model.cleanIntervals}`)}`;
8631
+ }
8632
+ const { p25, median: median2, p75 } = model.ppPerUsd;
8633
+ return ` ${name} ${pp(median2)} ${c.dim(`p25 ${pp(p25)} p75 ${pp(p75)} ${model.cleanIntervals} gaps`)}`;
8634
+ }
8635
+ function pp(n) {
8636
+ if (n === null) return "\u2014";
8637
+ return `${n.toFixed(2)} pp/$`;
8638
+ }
8639
+
7598
8640
  // packages/cli/src/index.ts
7599
8641
  function packageVersion() {
7600
8642
  let dir = dirname(fileURLToPath(import.meta.url));
@@ -7611,7 +8653,7 @@ function packageVersion() {
7611
8653
  return "0.0.0";
7612
8654
  }
7613
8655
  var program = new Command();
7614
- program.name("nnt").description("nom nom nom \u2014 your agent is eating tokens. The dashboard shows exactly how many.").version(packageVersion()).option("--db <path>", "database file (default: ~/.nomnomtokens/data.db)");
8656
+ program.name("nnt").description("nom nom nom \u2014 your agents are eating tokens. One local store shows where.").version(packageVersion()).option("--db <path>", "database file (default: ~/.nomnomtokens/data.db)");
7615
8657
  program.command("scan", { isDefault: false }).description("read new records from every detected source into the local store").option("-w, --watch", "stay running and ingest new records as they are written").option("-q, --quiet", "suppress the summary").action(async (opts) => {
7616
8658
  await scan({ ...opts, db: program.opts().db });
7617
8659
  });
@@ -7632,8 +8674,8 @@ program.command("statusline").description("read Claude Code session JSON on stdi
7632
8674
  program.command("doctor").description("check sources, hook wiring and the local store").action(async () => {
7633
8675
  await doctor({ db: program.opts().db });
7634
8676
  });
7635
- program.command("import <file>").description("import a CSV billing export or spreadsheet into the local store").option("--provider <name>", "provider label written on every row", "csv").option("--kind <kind>", "event kind (tokens, minutes, \u2026)", "tokens").option("-q, --quiet", "suppress the summary").action(async (file, opts) => {
7636
- await importCsvCommand({
8677
+ program.command("import <file>").description("import a billing CSV or an nnt archive JSON into the local store").option("--provider <name>", "provider label written on every row", "csv").option("--kind <kind>", "event kind (tokens, minutes, \u2026)", "tokens").option("-q, --quiet", "suppress the summary").action(async (file, opts) => {
8678
+ await importCommand({
7637
8679
  file,
7638
8680
  provider: opts.provider,
7639
8681
  kind: opts.kind,
@@ -7641,14 +8683,15 @@ program.command("import <file>").description("import a CSV billing export or spr
7641
8683
  db: program.opts().db
7642
8684
  });
7643
8685
  });
7644
- program.command("export").description("write filtered events as CSV or JSON (stdout or -o file)").option("--format <fmt>", "csv or json", "csv").option("-o, --out <file>", "write to a file instead of stdout").option("--range <range>", "24h | 7d | 30d | 90d | all", "30d").option("--provider <name>", "filter to one provider").option("-q, --quiet", "suppress the summary on stderr").action((opts) => {
7645
- const format = opts.format === "json" ? "json" : "csv";
8686
+ program.command("export").description("write filtered events as CSV, JSON, or an nnt archive").option("--format <fmt>", "csv, json, or nnt", "csv").option("-o, --out <file>", "write to a file instead of stdout").option("--range <range>", "24h | 7d | 30d | 90d | all", "30d").option("--provider <name>", "filter to one provider").option("--client <name>", "filter to a local client tag").option("-q, --quiet", "suppress the summary on stderr").action((opts) => {
8687
+ const format = opts.format === "json" || opts.format === "nnt" ? opts.format : "csv";
7646
8688
  const range = ["24h", "7d", "30d", "90d", "all"].includes(opts.range) ? opts.range : "30d";
7647
8689
  exportCommand({
7648
8690
  format,
7649
8691
  out: opts.out,
7650
8692
  range,
7651
8693
  provider: opts.provider,
8694
+ client: opts.client,
7652
8695
  quiet: opts.quiet,
7653
8696
  db: program.opts().db
7654
8697
  });
@@ -7670,6 +8713,37 @@ alerts.command("check").description("evaluate thresholds; exit 2 if any are firi
7670
8713
  db: program.opts().db
7671
8714
  });
7672
8715
  });
8716
+ program.command("audit").description("where spend went: cache, subagents, models, and tips").option("--days <n>", "calendar days to include (default 7)", (v) => Number.parseInt(v, 10), 7).option("--json", "print the audit report as JSON").option("-q, --quiet", "suppress empty-range hint").action((opts) => {
8717
+ auditCommand({
8718
+ days: opts.days,
8719
+ json: opts.json,
8720
+ quiet: opts.quiet,
8721
+ db: program.opts().db
8722
+ });
8723
+ });
8724
+ program.command("verdict").description("which Claude plan your weekly fill actually needs (Pro / 5x / 20x)").option("--json", "print the verdict as JSON").action((opts) => {
8725
+ verdictCommand({
8726
+ json: opts.json,
8727
+ db: program.opts().db
8728
+ });
8729
+ });
8730
+ program.command("weigh").description("weekly limit points per list-price dollar, from gaps one model dominates").option("--json", "print the weigh report as JSON").action((opts) => {
8731
+ weighCommand({
8732
+ json: opts.json,
8733
+ db: program.opts().db
8734
+ });
8735
+ });
8736
+ program.command("otel").description("opt-in OTLP/HTTP export of local numbers (never on by default)").requiredOption("--endpoint <url>", "OTLP HTTP base (we POST /v1/metrics)").option("--once", "export current store and exit (default)").option("-w, --watch", "scan once, then export").option("--scan", "run a catch-up scan before exporting").option("--include-labels", "attach local project labels (off by default)").option("-q, --quiet", "suppress the summary").action(async (opts) => {
8737
+ await otelCommand({
8738
+ endpoint: opts.endpoint,
8739
+ once: opts.once,
8740
+ watch: opts.watch,
8741
+ scan: opts.scan,
8742
+ includeLabels: opts.includeLabels,
8743
+ quiet: opts.quiet,
8744
+ db: program.opts().db
8745
+ });
8746
+ });
7673
8747
  var COMMANDS = /* @__PURE__ */ new Set([
7674
8748
  "scan",
7675
8749
  "serve",
@@ -7680,6 +8754,10 @@ var COMMANDS = /* @__PURE__ */ new Set([
7680
8754
  "export",
7681
8755
  "prices",
7682
8756
  "alerts",
8757
+ "audit",
8758
+ "verdict",
8759
+ "weigh",
8760
+ "otel",
7683
8761
  "help"
7684
8762
  ]);
7685
8763
  var META_FLAGS = /* @__PURE__ */ new Set(["-h", "--help", "-V", "--version"]);