estela 0.2.3 → 0.3.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.
package/README.md CHANGED
@@ -273,6 +273,61 @@ anything you use on claude.ai or another machine counts too but isn't on this
273
273
  one. Copilot is left out because it doesn't record reliable tokens. Everything
274
274
  is calculated on your machine.
275
275
 
276
+ ## Is your Claude plan the right one?
277
+
278
+ ```sh
279
+ estela plan set max5 # pro | max5 | max20; add --since YYYY-MM-DD if you had it before today
280
+ estela plan # last 28 days; --days N, --json, --no-color
281
+ ```
282
+
283
+ Answers "is the Claude plan I pay for too big or too small?" with your own data:
284
+ the 5-hour and weekly percentages Claude Code sends to the status line (stored
285
+ since 0.2.4, only in the terminal), and nothing else. Example (made-up data):
286
+
287
+ ```
288
+ estela plan · Max 5x (since 2026-10-01) · 26 days with data
289
+
290
+ 5-hour windows 41 windows · median peak 22% · p90 38% · max 61%
291
+ Caps (≥95%) 0
292
+ Week median peak 34% · max 47% · 0 weeks ≥ 90%
293
+ Blocks (429) 0 5-hour · 0 weekly, from Claude Code's transcripts
294
+
295
+ If you were on…
296
+ Pro ($20) equivalent p90 190% · would have blocked you in 29 of 41 windows
297
+ Max 20x ($200) no caps, same as now
298
+
299
+ Recommendation Your current plan fits your usage.
300
+ ```
301
+
302
+ - **It never invents or estimates a plan's limit.** Other plans are compared only
303
+ on the 5-hour window, using the per-session multiplier Anthropic publishes
304
+ (Max 5x = 5 × Pro, Max 20x = 20 × Pro;
305
+ [source](https://support.claude.com/en/articles/11049741-what-is-the-max-plan)):
306
+ an equivalent peak is your peak × your multiplier ÷ the other plan's. Anthropic
307
+ doesn't publish the weekly limit between plans, so that comparison isn't made.
308
+ It never says how much you have left.
309
+ - A window with a peak of 95% or more is a cap. The time stopped after a cap
310
+ (from the first sample at 99% to the reset) is an upper bound: we don't know if
311
+ you kept working.
312
+ - "Blocks (429)" are the rate-limit responses Claude Code wrote in its
313
+ transcripts. They're official and go back before 0.2.4, but carry no
314
+ percentage, so they're shown apart and don't enter the calculation.
315
+ - With fewer than 7 days of data there's no recommendation; from 7 to 13 it's
316
+ provisional. After a plan change only data since then counts.
317
+ - Prices are Anthropic's website prices on the date in the output; check them.
318
+ Anthropic may change limits: run it again every month.
319
+
320
+ `--json` returns the same figures with a stable structure (`version: 1`): `plan`,
321
+ `period` (`from`, `to`, `days`, `daysWithData`, `samples`), `dataState`
322
+ (`none` | `insufficient` | `provisional` | `ok`), `fiveHour` (`windows`,
323
+ `medianPeak`, `p90Peak`, `maxPeak`, `caps`, `stoppedMinutesUpperBound`), `week`
324
+ (`weeks`, `medianPeak`, `maxPeak`, `weeksAtOrAbove90`), `blocks429`,
325
+ `alternatives[]` (`plan`, `direction`, `monthlyUsd`, `monthlyDiffUsd`,
326
+ `p90Equivalent`, `maxEquivalent`, `blockedWindows`, `capsAvoided`),
327
+ `recommendation` (`kind`: `tight` | `enough` | `almost` | `fits`, `plan`,
328
+ `provisional`, `text`, or `null`), `weeklyBetweenPlans: "not_published"`,
329
+ `sources` and `plans`. New fields may be added; none change meaning.
330
+
276
331
  ## A line under every Claude Code session
277
332
 
278
333
  ```sh
@@ -306,6 +361,16 @@ won't overwrite it: `--force` replaces it, `--append` shows yours after
306
361
  Estela's. `estela statusline uninstall` removes it (and puts yours back if
307
362
  you used `--append`).
308
363
 
364
+ The status line only runs in Claude Code in the terminal: the VS Code
365
+ extension doesn't show it.
366
+
367
+ Since 0.2.4, the status line stores your 5-hour and weekly percentages locally
368
+ (`~/.estela/rate-limits.jsonl`) so `estela plan` can analyse them. Nothing leaves
369
+ your machine. Only what Claude Code sends is kept — each percentage, its reset
370
+ time and the Claude Code version — at most every 5 minutes unless a figure moves,
371
+ and for 120 days. Transcripts don't carry these percentages, so the history
372
+ starts the day you install 0.2.4.
373
+
309
374
  ## Hours no import will infer
310
375
 
311
376
  Meetings, travel, research, and development without an agent that didn't leave
package/dist/cli.js CHANGED
@@ -35,6 +35,7 @@ var __importStar = (this && this.__importStar) || (function () {
35
35
  })();
36
36
  Object.defineProperty(exports, "__esModule", { value: true });
37
37
  exports.breakdownOfTurn = void 0;
38
+ const node_os_1 = require("node:os");
38
39
  const node_path_1 = require("node:path");
39
40
  const node_readline_1 = require("node:readline");
40
41
  // `node:sqlite` avisa de que es experimental en cada arranque. Es ruido para
@@ -63,6 +64,12 @@ const invoice_pdf_js_1 = require("./export/invoice-pdf.js");
63
64
  const share_js_1 = require("./export/share.js");
64
65
  const server_js_1 = require("./server.js");
65
66
  const updateCheck_js_1 = require("./updateCheck.js");
67
+ const plan_js_1 = require("./metrics/plan.js");
68
+ const plan_blocks_js_1 = require("./plan-blocks.js");
69
+ const plan_prices_js_1 = require("./plan-prices.js");
70
+ const plan_report_js_1 = require("./plan-report.js");
71
+ const rate_history_js_1 = require("./rate-history.js");
72
+ const statusline_cache_js_1 = require("./statusline-cache.js");
66
73
  const publish_js_1 = require("./publish.js");
67
74
  const sync_js_1 = require("./sync.js");
68
75
  const team_js_1 = require("./sync/team.js");
@@ -169,6 +176,15 @@ estela — registro de horas para desarrollo asistido por IA
169
176
  Copilot queda fuera: no guarda tokens fiables. Antes de calcular
170
177
  lee los transcripts desde el lunes (--no-refresh lo evita).
171
178
  A diferencia de ai-cost, que reparte dinero, esto reparte tokens.
179
+
180
+ estela plan [--days <n>] [--json] [--no-color]
181
+ estela plan set pro|max5|max20 [--since AAAA-MM-DD]
182
+ ¿El plan de Claude que pagas te sobra o te falta? Analiza los
183
+ porcentajes de 5 h y semana que Claude Code manda a la status line
184
+ (solo en la terminal) y los compara con los otros planes usando el
185
+ multiplicador por sesión que publica Anthropic. El límite semanal
186
+ entre planes no se compara: no se publica. Primero declara tu plan
187
+ con "plan set". Por defecto, los últimos 28 días.
172
188
  estela statusline install [--force | --append]
173
189
  Una línea bajo cada sesión de Claude Code: proyecto, horas medidas
174
190
  hoy, la parte de tu consumo de la semana que es de este proyecto y,
@@ -369,6 +385,15 @@ estela — time tracking for AI-assisted development
369
385
  left out: it doesn't record reliable tokens. Before calculating it reads transcripts since Monday
370
386
  (--no-refresh skips that). Unlike ai-cost, which splits money, this
371
387
  splits tokens.
388
+
389
+ estela plan [--days <n>] [--json] [--no-color]
390
+ estela plan set pro|max5|max20 [--since YYYY-MM-DD]
391
+ Is your Claude plan too big or too small? It analyses the 5-hour
392
+ and weekly percentages Claude Code sends to the status line (only
393
+ in the terminal) and compares them with the other plans using the
394
+ per-session multiplier Anthropic publishes. The weekly limit isn't
395
+ compared across plans: it isn't published. Declare your plan first
396
+ with "plan set". Defaults to the last 28 days.
372
397
  estela statusline install [--force | --append]
373
398
  A line under every Claude Code session: project, hours measured
374
399
  today, this project's share of your usage this week and, if Claude
@@ -1544,6 +1569,90 @@ async function cmdUnpublish(args, dbPath) {
1544
1569
  db.close();
1545
1570
  }
1546
1571
  }
1572
+ /**
1573
+ * `estela plan`: ¿el plan de Claude que pagas es el que te conviene?
1574
+ *
1575
+ * Solo con lo que manda Claude Code a la status line (rate-limits.jsonl) y el
1576
+ * multiplicador por sesión que publica Anthropic. Ver metrics/plan.ts.
1577
+ */
1578
+ function cmdPlan(args, dbPath) {
1579
+ const db = (0, schema_js_1.openDatabase)(dbPath);
1580
+ try {
1581
+ const declared = store.getClaudePlan(db);
1582
+ const json = args.flags["json"] === true;
1583
+ if (!declared || !(0, plan_prices_js_1.planById)(declared.plan)) {
1584
+ if (json)
1585
+ console.log(JSON.stringify({ version: 1, plan: null }, null, 2));
1586
+ else
1587
+ console.log((0, plan_report_js_1.renderNoPlan)());
1588
+ return;
1589
+ }
1590
+ const days = str(args, "days") === undefined ? 28 : Number(str(args, "days"));
1591
+ if (!Number.isInteger(days) || days < 1 || days > rate_history_js_1.KEEP_DAYS) {
1592
+ throw new UserError((0, index_js_1.tr) `--days tiene que ser un número entero de 1 a ${rate_history_js_1.KEEP_DAYS}.`);
1593
+ }
1594
+ const now = new Date();
1595
+ const file = (0, rate_history_js_1.historyPath)(dbPath);
1596
+ try {
1597
+ (0, rate_history_js_1.pruneSamples)(file, now);
1598
+ }
1599
+ catch { /* sin rotar se analiza igual */ }
1600
+ const all = (0, rate_history_js_1.readSamples)(file);
1601
+ const analysis = (0, plan_js_1.analyzePlan)({ samples: all, plan: declared.plan, since: declared.since, now, days });
1602
+ // Los 429 que Claude Code dejó en sus transcripts, en el mismo período.
1603
+ let blocks = null;
1604
+ try {
1605
+ const found = (0, plan_blocks_js_1.scanBlocks)((0, node_path_1.join)((0, node_os_1.homedir)(), ".claude", "projects"), (0, plan_blocks_js_1.blocksCachePath)(dbPath), now)
1606
+ .filter((b) => b.at >= analysis.from.getTime() && b.at <= now.getTime());
1607
+ blocks = { fiveHour: found.filter((b) => b.type === "five_hour").length,
1608
+ sevenDay: found.filter((b) => b.type === "seven_day").length };
1609
+ }
1610
+ catch { /* sin transcripts legibles, la línea no sale */ }
1611
+ if (json) {
1612
+ console.log(JSON.stringify((0, plan_report_js_1.planToJson)(analysis, { blocks, now }), null, 2));
1613
+ return;
1614
+ }
1615
+ const color = args.flags["no-color"] !== true && !process.env["NO_COLOR"] && process.stdout.isTTY === true;
1616
+ if (analysis.dataState === "none") {
1617
+ // Sin ninguna muestra nunca, y la status line vio entradas sin rate_limits: API key.
1618
+ const apiKey = all.length === 0 && (0, statusline_cache_js_1.loadCache)(dbPath).noLimitsAt !== undefined;
1619
+ console.log((0, plan_report_js_1.renderNoData)(analysis, apiKey, color));
1620
+ return;
1621
+ }
1622
+ console.log((0, plan_report_js_1.renderPlanReport)(analysis, { color, now, blocks }));
1623
+ }
1624
+ finally {
1625
+ db.close();
1626
+ }
1627
+ }
1628
+ /** `estela plan set pro|max5|max20 [--since AAAA-MM-DD]`. */
1629
+ function cmdPlanSet(args, dbPath) {
1630
+ const id = args._[2] ?? "";
1631
+ const plan = (0, plan_prices_js_1.planById)(id);
1632
+ if (!plan)
1633
+ throw new UserError((0, index_js_1.tr) `Plan desconocido: "${id}". Usa pro, max5 o max20.`);
1634
+ const sinceText = str(args, "since");
1635
+ let since = new Date();
1636
+ if (sinceText !== undefined) {
1637
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(sinceText) || Number.isNaN(new Date(`${sinceText}T00:00:00`).getTime())) {
1638
+ throw new UserError((0, index_js_1.tr) `--since tiene que ser una fecha AAAA-MM-DD.`);
1639
+ }
1640
+ since = new Date(`${sinceText}T00:00:00`);
1641
+ }
1642
+ const db = (0, schema_js_1.openDatabase)(dbPath);
1643
+ try {
1644
+ store.setClaudePlan(db, plan.id, since);
1645
+ const d = since;
1646
+ const fecha = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
1647
+ console.log((0, index_js_1.tr) `Plan declarado: ${plan.name}, desde ${fecha}. El análisis solo usa lo guardado desde esa fecha.`);
1648
+ if (sinceText === undefined) {
1649
+ console.log((0, index_js_1.tr) `Si ya lo tenías antes, indica desde cuándo: estela plan set ${plan.id} --since AAAA-MM-DD`);
1650
+ }
1651
+ }
1652
+ finally {
1653
+ db.close();
1654
+ }
1655
+ }
1547
1656
  /**
1548
1657
  * `estela sync enable --project <id>` — opt-in explícito, proyecto por
1549
1658
  * proyecto. Sin esta fila, ese proyecto no sale de esta máquina nunca, sea
@@ -2134,6 +2243,7 @@ function cmdStatuslineInstall(args) {
2134
2243
  }
2135
2244
  if (plan.status === "already") {
2136
2245
  console.log((0, index_js_1.tr) `La status line de Estela ya está instalada en ${path}.`);
2246
+ console.log((0, index_js_1.tr) `Solo en Claude Code en la terminal: la extensión de VS Code no enseña la status line.`);
2137
2247
  return;
2138
2248
  }
2139
2249
  if (plan.status === "conflict") {
@@ -2158,6 +2268,9 @@ function cmdStatuslineInstall(args) {
2158
2268
  ? (0, index_js_1.tr) `Status line de Estela instalada en lugar de: ${plan.previous ?? ""}`
2159
2269
  : (0, index_js_1.tr) `Status line de Estela instalada en ${path}.`);
2160
2270
  console.log((0, index_js_1.tr) `Se verá en la próxima sesión de Claude Code (o al reiniciar la actual).`);
2271
+ // Comprobado: la extensión de VS Code no ejecuta la status line. Sin este
2272
+ // aviso, quien trabaja ahí cree que está instalada y no la ve nunca.
2273
+ console.log((0, index_js_1.tr) `Solo en Claude Code en la terminal: la extensión de VS Code no enseña la status line.`);
2161
2274
  if (!(0, statusline_install_js_1.estelaOnPath)()) {
2162
2275
  console.log((0, index_js_1.tr) `\nAviso: "estela" no está en tu PATH, así que Claude Code no podrá ejecutarla.`);
2163
2276
  console.log((0, index_js_1.tr) `Instálala de forma global: npm install -g estela`);
@@ -2462,7 +2575,10 @@ async function main() {
2462
2575
  (0, client_js_1.setClientVersion)(VERSION);
2463
2576
  const dbPath = str(args, "db") ?? schema_js_1.DEFAULT_DB_PATH;
2464
2577
  // `estela unpublish <panel>` lleva el panel suelto, sin flag: no es un subcomando.
2465
- const command = args._[0] === "unpublish" ? "unpublish" : args._.join(" ");
2578
+ const command = args._[0] === "unpublish" ? "unpublish"
2579
+ // `estela plan set max5`: el plan va suelto, como el panel de unpublish.
2580
+ : args._[0] === "plan" && args._[1] === "set" ? "plan set"
2581
+ : args._.join(" ");
2466
2582
  // Se llama aquí, no al final: un comando que falla a medias (por ejemplo
2467
2583
  // "estela import" sin repositorios) debe avisar igual de que hay una
2468
2584
  // versión nueva, no solo los que terminan bien.
@@ -2549,6 +2665,12 @@ async function main() {
2549
2665
  case "unpublish":
2550
2666
  await cmdUnpublish(args, dbPath);
2551
2667
  break;
2668
+ case "plan":
2669
+ cmdPlan(args, dbPath);
2670
+ break;
2671
+ case "plan set":
2672
+ cmdPlanSet(args, dbPath);
2673
+ break;
2552
2674
  case "export":
2553
2675
  await cmdExport(args, dbPath);
2554
2676
  break;
package/dist/db/schema.js CHANGED
@@ -269,6 +269,16 @@ CREATE TABLE IF NOT EXISTS personal_sync_state (
269
269
  entity_type TEXT PRIMARY KEY,
270
270
  synced_up_to TEXT NOT NULL DEFAULT ''
271
271
  );
272
+
273
+ -- El plan de Claude que declara quien usa Estela (estela plan set): el JSON
274
+ -- de la status line no lo dice. Una fila por cambio, para conservar el
275
+ -- historial; el vigente es el de 'since' más reciente.
276
+ CREATE TABLE IF NOT EXISTS claude_plans (
277
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
278
+ plan TEXT NOT NULL,
279
+ since TEXT NOT NULL,
280
+ declared_at TEXT NOT NULL
281
+ );
272
282
  `;
273
283
  /**
274
284
  * Migraciones.
@@ -304,4 +304,13 @@ export declare function entryDetailForTeam(db: DatabaseSync, projectId: string):
304
304
  }[];
305
305
  export declare function getAiConsent(db: DatabaseSync, projectId: string): AiConsentRecord | null;
306
306
  export declare function setAiConsent(db: DatabaseSync, projectId: string, requested: boolean, decision: AiConsentDecision): void;
307
+ export interface DeclaredPlan {
308
+ readonly plan: string;
309
+ readonly since: Date;
310
+ }
311
+ /** El plan vigente: el de `since` más reciente. null si nunca se declaró. */
312
+ export declare function getClaudePlan(db: DatabaseSync): DeclaredPlan | null;
313
+ /** Añade un cambio de plan; los anteriores se conservan. */
314
+ export declare function setClaudePlan(db: DatabaseSync, plan: string, since: Date, at?: Date): void;
315
+ export declare function listClaudePlans(db: DatabaseSync): DeclaredPlan[];
307
316
  //# sourceMappingURL=store.d.ts.map
package/dist/db/store.js CHANGED
@@ -61,6 +61,9 @@ exports.entryAiByDay = entryAiByDay;
61
61
  exports.entryDetailForTeam = entryDetailForTeam;
62
62
  exports.getAiConsent = getAiConsent;
63
63
  exports.setAiConsent = setAiConsent;
64
+ exports.getClaudePlan = getClaudePlan;
65
+ exports.setClaudePlan = setClaudePlan;
66
+ exports.listClaudePlans = listClaudePlans;
64
67
  const node_crypto_1 = require("node:crypto");
65
68
  const shared_1 = require("@estela/shared");
66
69
  const cost_js_1 = require("../pricing/cost.js");
@@ -982,4 +985,19 @@ function setAiConsent(db, projectId, requested, decision) {
982
985
  requested = excluded.requested, decision = excluded.decision, decided_at = excluded.decided_at
983
986
  `).run(projectId, requested ? 1 : 0, decision, decision === "pending" ? null : iso(new Date()));
984
987
  }
988
+ /** El plan vigente: el de `since` más reciente. null si nunca se declaró. */
989
+ function getClaudePlan(db) {
990
+ const row = db.prepare("SELECT plan, since FROM claude_plans ORDER BY since DESC, id DESC LIMIT 1")
991
+ .get();
992
+ return row ? { plan: row.plan, since: new Date(row.since) } : null;
993
+ }
994
+ /** Añade un cambio de plan; los anteriores se conservan. */
995
+ function setClaudePlan(db, plan, since, at = new Date()) {
996
+ db.prepare("INSERT INTO claude_plans (plan, since, declared_at) VALUES (?, ?, ?)")
997
+ .run(plan, iso(since), iso(at));
998
+ }
999
+ function listClaudePlans(db) {
1000
+ return db.prepare("SELECT plan, since FROM claude_plans ORDER BY since, id").all()
1001
+ .map((r) => ({ plan: r.plan, since: new Date(r.since) }));
1002
+ }
985
1003
  //# sourceMappingURL=store.js.map
package/dist/i18n/en.js CHANGED
@@ -577,5 +577,52 @@ exports.EN = {
577
577
  "No hay ningún panel publicado de \"{0}\". Míralos con: estela unpublish": "There's no published dashboard for \"{0}\". See them with: estela unpublish",
578
578
  // ── ESTELA_OFFLINE ──
579
579
  "ESTELA_OFFLINE=1: Estela no se conecta a la red. Quita la variable para usar tu cuenta.": "ESTELA_OFFLINE=1: Estela doesn't connect to the network. Unset it to use your account.",
580
+ // ── statusline install: VS Code ──
581
+ "Solo en Claude Code en la terminal: la extensión de VS Code no enseña la status line.": "Only in Claude Code in the terminal: the VS Code extension doesn't show the status line.",
582
+ // ── estela plan ──
583
+ "--days tiene que ser un número entero de 1 a {0}.": "--days has to be a whole number from 1 to {0}.",
584
+ "Plan desconocido: \"{0}\". Usa pro, max5 o max20.": "Unknown plan: \"{0}\". Use pro, max5 or max20.",
585
+ "--since tiene que ser una fecha AAAA-MM-DD.": "--since has to be a YYYY-MM-DD date.",
586
+ "Plan declarado: {0}, desde {1}. El análisis solo usa lo guardado desde esa fecha.": "Plan declared: {0}, since {1}. The analysis only uses what was stored from that date.",
587
+ "Si ya lo tenías antes, indica desde cuándo: estela plan set {0} --since AAAA-MM-DD": "If you had it before, say since when: estela plan set {0} --since YYYY-MM-DD",
588
+ "Ventanas de 5 h": "5-hour windows",
589
+ "ninguna completa todavía": "none yet",
590
+ "{0} ventanas · pico mediano {1} % · p90 {2} % · máximo {3} %": "{0} windows · median peak {1}% · p90 {2}% · max {3}%",
591
+ "Topes (≥95 %)": "Caps (≥95%)",
592
+ "Parado tras topes": "Stopped after caps",
593
+ "hasta {0} (estimado: no sabemos si seguías trabajando)": "up to {0} (estimated: we don't know if you kept working)",
594
+ "Semana": "Week",
595
+ "sin datos de la semana": "no weekly data",
596
+ "pico mediano {0} % · máximo {1} % · {2} semanas ≥ 90 %": "median peak {0}% · max {1}% · {2} weeks ≥ 90%",
597
+ "Bloqueos (429)": "Blocks (429)",
598
+ "{0} de 5 h · {1} semanales, según los transcripts de Claude Code": "{0} 5-hour · {1} weekly, from Claude Code's transcripts",
599
+ "Si estuvieras en…": "If you were on…",
600
+ "Datos de {0} días. La recomendación necesita al menos 14.": "{0} days of data. The recommendation needs at least 14.",
601
+ "Recomendación (provisional, {0} días)": "Recommendation (provisional, {0} days)",
602
+ "Recomendación": "Recommendation",
603
+ "Límite semanal entre planes: Anthropic no publica la relación. No lo estimamos.": "Weekly limit between plans: Anthropic doesn't publish the ratio. We don't estimate it.",
604
+ "Basado en el multiplicador por sesión publicado por Anthropic:": "Based on the per-session multiplier published by Anthropic:",
605
+ "Anthropic puede cambiar los límites; repite el análisis cada mes.": "Anthropic may change the limits; run this again every month.",
606
+ "Precios según la web de Anthropic a {0}; compruébalo: {1}": "Prices from Anthropic's website as of {0}; check them: {1}",
607
+ "Esa fecha tiene más de {0} días: los precios pueden estar desactualizados.": "That date is more than {0} days old: prices may be out of date.",
608
+ "(desde {0})": "(since {0})",
609
+ "{0} días con datos": "{0} days with data",
610
+ "sin topes, igual que ahora": "no caps, same as now",
611
+ "p90 equivalente {0} % · habrías evitado {1} de {2} topes (con el uso que llegó a registrarse)": "equivalent p90 {0}% · you'd have avoided {1} of {2} caps (with the usage that got recorded)",
612
+ "p90 equivalente {0} % · no te habría bloqueado en ninguna de las {1} ventanas": "equivalent p90 {0}% · wouldn't have blocked you in any of the {1} windows",
613
+ "p90 equivalente {0} % · te habría bloqueado en {1} de {2} ventanas": "equivalent p90 {0}% · would have blocked you in {1} of {2} windows",
614
+ "Vas justo con tu plan. Con {0} habrías evitado {1} de {2} topes.": "Your plan is tight for you. With {0} you'd have avoided {1} of {2} caps.",
615
+ "Vas justo con tu plan.": "Your plan is tight for you.",
616
+ "Con los datos de estas {0} semanas, el plan {1} te habría bastado en todas las ventanas de 5 h.": "Based on these {0} weeks of data, the {1} plan would have been enough in every 5-hour window.",
617
+ "El plan {0} te habría bastado casi siempre: te habría bloqueado {1} veces en estas semanas.": "The {0} plan would have been enough almost always: it would have blocked you {1} times in these weeks.",
618
+ "Tu plan actual encaja con tu uso.": "Your current plan fits your usage.",
619
+ "{0} menos al mes, según la web de Anthropic a {1}; compruébalo.": "{0} less per month, per Anthropic's website as of {1}; check it.",
620
+ "{0} más al mes, según la web de Anthropic a {1}; compruébalo.": "{0} more per month, per Anthropic's website as of {1}; check it.",
621
+ "estela plan compara tu uso real de Claude con los otros planes. Primero dime cuál pagas:": "estela plan compares your real Claude usage with the other plans. First tell it which one you pay for:",
622
+ "Si ya lo tenías antes de hoy, añade desde cuándo: --since AAAA-MM-DD": "If you had it before today, add since when: --since YYYY-MM-DD",
623
+ "Este análisis solo funciona con planes Pro o Max.": "This analysis only works with Pro or Max plans.",
624
+ "Aún no hay datos. Instala la status line (estela statusline install) y vuelve en unos días.": "No data yet. Install the status line (estela statusline install) and come back in a few days.",
625
+ "Solo se recogen en Claude Code en la terminal: la extensión de VS Code no ejecuta la status line.": "They're only collected in Claude Code in the terminal: the VS Code extension doesn't run the status line.",
626
+ "{0} $": "${0}",
580
627
  };
581
628
  //# sourceMappingURL=en.js.map
@@ -0,0 +1,110 @@
1
+ import { type ClaudePlan, type ClaudePlanId } from "../plan-prices.js";
2
+ import type { RateSample } from "../rate-history.js";
3
+ /**
4
+ * "¿El plan de Claude que pago es el que me conviene?"
5
+ *
6
+ * Funciones puras sobre el historial de la status line (rate-limits.jsonl):
7
+ * entran muestras, salen cifras, y cada cifra tiene su test.
8
+ *
9
+ * La regla de siempre, más estricta aquí que en ningún sitio: Estela no
10
+ * inventa ni estima el límite de ningún plan. Usa solo
11
+ *
12
+ * - los porcentajes que manda Claude Code, tal cual, y
13
+ * - el multiplicador por sesión de 5 h que publica Anthropic (plan-prices.ts).
14
+ *
15
+ * Por eso la comparación entre planes es solo de la ventana de 5 h: del
16
+ * límite semanal Anthropic no publica la relación entre planes, y no se
17
+ * calcula. Y nada de "te quedan X" ni de cuándo te vas a quedar sin cuota.
18
+ */
19
+ /** Pico desde el que una ventana cuenta como tope. */
20
+ export declare const CAP_AT = 95;
21
+ export declare const MIN_DAYS = 7;
22
+ export declare const FULL_DAYS = 14;
23
+ export interface FiveHourWindow {
24
+ /** `resets_at` de la ventana (epoch en s). */
25
+ readonly reset: number;
26
+ readonly peak: number;
27
+ readonly samples: number;
28
+ readonly capped: boolean;
29
+ /** Desde la primera muestra ≥ 99 % hasta el reinicio (o hasta ahora). Cota superior, estimada. */
30
+ readonly stoppedMs: number;
31
+ }
32
+ export interface WeekWindow {
33
+ readonly reset: number;
34
+ readonly peak: number;
35
+ }
36
+ export interface Alternative {
37
+ readonly plan: ClaudePlan;
38
+ readonly direction: "lower" | "higher";
39
+ readonly p90Equivalent: number;
40
+ readonly maxEquivalent: number;
41
+ /** Ventanas cuyo pico equivalente pasa de 100: te habrían bloqueado. */
42
+ readonly blockedWindows: number;
43
+ /** Subiendo de plan: topes de ahora cuyo pico equivalente se queda por debajo del tope. */
44
+ readonly capsAvoided: number;
45
+ }
46
+ export type Recommendation = {
47
+ readonly kind: "tight";
48
+ readonly higher: ClaudePlan | null;
49
+ readonly capsAvoided: number | null;
50
+ } | {
51
+ readonly kind: "enough";
52
+ readonly plan: ClaudePlan;
53
+ readonly weeks: number;
54
+ } | {
55
+ readonly kind: "almost";
56
+ readonly plan: ClaudePlan;
57
+ readonly blocked: number;
58
+ } | {
59
+ readonly kind: "fits";
60
+ };
61
+ export type DataState = "none" | "insufficient" | "provisional" | "ok";
62
+ export interface PlanAnalysis {
63
+ readonly plan: ClaudePlan;
64
+ readonly since: Date;
65
+ readonly from: Date;
66
+ readonly to: Date;
67
+ readonly days: number;
68
+ readonly daysWithData: number;
69
+ readonly samples: number;
70
+ readonly dataState: DataState;
71
+ readonly fiveHour: {
72
+ readonly windows: readonly FiveHourWindow[];
73
+ readonly medianPeak: number | null;
74
+ readonly p90Peak: number | null;
75
+ readonly maxPeak: number | null;
76
+ readonly caps: number;
77
+ readonly stoppedMs: number;
78
+ };
79
+ readonly week: {
80
+ readonly weeks: readonly WeekWindow[];
81
+ readonly medianPeak: number | null;
82
+ readonly maxPeak: number | null;
83
+ readonly weeksAtOrAbove90: number;
84
+ };
85
+ readonly alternatives: readonly Alternative[];
86
+ /** null con menos de 7 días de datos. */
87
+ readonly recommendation: Recommendation | null;
88
+ }
89
+ export interface PlanAnalysisInput {
90
+ readonly samples: readonly RateSample[];
91
+ readonly plan: ClaudePlanId;
92
+ readonly since: Date;
93
+ readonly now: Date;
94
+ /** Período, en días hacia atrás desde `now`. */
95
+ readonly days: number;
96
+ /** Para contar los días con datos en tu zona horaria. Por defecto, la de esta máquina. */
97
+ readonly localDay?: (ms: number) => string;
98
+ }
99
+ export declare function analyzePlan(input: PlanAnalysisInput): PlanAnalysis;
100
+ /**
101
+ * Una ventana por `resets_at`, redondeado al minuto: dos sesiones pueden
102
+ * mandar el mismo reinicio con segundos distintos. Una muestra sin reinicio
103
+ * no se puede asignar a ninguna ventana y no cuenta.
104
+ */
105
+ export declare function fiveHourWindows(samples: readonly RateSample[], now: Date): FiveHourWindow[];
106
+ export declare function weekWindows(samples: readonly RateSample[]): WeekWindow[];
107
+ export declare function median(values: readonly number[]): number | null;
108
+ /** Percentil 90 por rango más cercano: siempre es un valor observado. */
109
+ export declare function p90(values: readonly number[]): number | null;
110
+ //# sourceMappingURL=plan.d.ts.map
@@ -0,0 +1,175 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.FULL_DAYS = exports.MIN_DAYS = exports.CAP_AT = void 0;
4
+ exports.analyzePlan = analyzePlan;
5
+ exports.fiveHourWindows = fiveHourWindows;
6
+ exports.weekWindows = weekWindows;
7
+ exports.median = median;
8
+ exports.p90 = p90;
9
+ const plan_prices_js_1 = require("../plan-prices.js");
10
+ /**
11
+ * "¿El plan de Claude que pago es el que me conviene?"
12
+ *
13
+ * Funciones puras sobre el historial de la status line (rate-limits.jsonl):
14
+ * entran muestras, salen cifras, y cada cifra tiene su test.
15
+ *
16
+ * La regla de siempre, más estricta aquí que en ningún sitio: Estela no
17
+ * inventa ni estima el límite de ningún plan. Usa solo
18
+ *
19
+ * - los porcentajes que manda Claude Code, tal cual, y
20
+ * - el multiplicador por sesión de 5 h que publica Anthropic (plan-prices.ts).
21
+ *
22
+ * Por eso la comparación entre planes es solo de la ventana de 5 h: del
23
+ * límite semanal Anthropic no publica la relación entre planes, y no se
24
+ * calcula. Y nada de "te quedan X" ni de cuándo te vas a quedar sin cuota.
25
+ */
26
+ /** Pico desde el que una ventana cuenta como tope. */
27
+ exports.CAP_AT = 95;
28
+ /** Desde aquí se cuenta el tiempo parado tras un tope. */
29
+ const STOPPED_FROM = 99;
30
+ /** Una semana con este pico o más es "ir justo". */
31
+ const TIGHT_WEEK_AT = 90;
32
+ const TIGHT_CAPS_PER_WEEK = 2;
33
+ /** Un plan inferior "basta" si no habría bloqueado nunca y su p90 equivalente no pasa de aquí. */
34
+ const ENOUGH_P90 = 80;
35
+ const ALMOST_MAX_BLOCKS = 3;
36
+ exports.MIN_DAYS = 7;
37
+ exports.FULL_DAYS = 14;
38
+ function analyzePlan(input) {
39
+ const plan = (0, plan_prices_js_1.planById)(input.plan);
40
+ if (!plan)
41
+ throw new Error(`plan desconocido: ${input.plan}`);
42
+ const to = input.now;
43
+ const from = new Date(Math.max(input.since.getTime(), to.getTime() - input.days * 86_400_000));
44
+ const localDay = input.localDay ?? defaultLocalDay;
45
+ const samples = dedupe(input.samples)
46
+ .filter((s) => s.ts >= from.getTime() && s.ts <= to.getTime())
47
+ .sort((a, b) => a.ts - b.ts);
48
+ const daysWithData = new Set(samples.map((s) => localDay(s.ts))).size;
49
+ const windows = fiveHourWindows(samples, to);
50
+ const peaks = windows.map((w) => w.peak);
51
+ const weeks = weekWindows(samples);
52
+ const weekPeaks = weeks.map((w) => w.peak);
53
+ const caps = windows.filter((w) => w.capped).length;
54
+ const alternatives = plan_prices_js_1.CLAUDE_PLANS.filter((p) => p.id !== plan.id).map((target) => {
55
+ const factor = plan.sessionMultiplier / target.sessionMultiplier;
56
+ const eq = windows.map((w) => w.peak * factor);
57
+ return {
58
+ plan: target,
59
+ direction: target.sessionMultiplier < plan.sessionMultiplier ? "lower" : "higher",
60
+ p90Equivalent: p90(eq) ?? 0,
61
+ maxEquivalent: eq.length ? Math.max(...eq) : 0,
62
+ blockedWindows: eq.filter((e) => e > 100).length,
63
+ capsAvoided: factor < 1 ? windows.filter((w) => w.capped && w.peak * factor < exports.CAP_AT).length : 0,
64
+ };
65
+ });
66
+ const dataState = samples.length === 0 ? "none"
67
+ : daysWithData < exports.MIN_DAYS ? "insufficient"
68
+ : daysWithData < exports.FULL_DAYS ? "provisional" : "ok";
69
+ const analysis = {
70
+ plan, since: input.since, from, to, days: input.days, daysWithData, samples: samples.length, dataState,
71
+ fiveHour: {
72
+ windows, medianPeak: median(peaks), p90Peak: p90(peaks), maxPeak: peaks.length ? Math.max(...peaks) : null,
73
+ caps, stoppedMs: windows.reduce((s, w) => s + w.stoppedMs, 0),
74
+ },
75
+ week: {
76
+ weeks, medianPeak: median(weekPeaks), maxPeak: weekPeaks.length ? Math.max(...weekPeaks) : null,
77
+ weeksAtOrAbove90: weekPeaks.filter((p) => p >= TIGHT_WEEK_AT).length,
78
+ },
79
+ alternatives,
80
+ };
81
+ return {
82
+ ...analysis,
83
+ recommendation: dataState === "provisional" || dataState === "ok" ? recommend(analysis) : null,
84
+ };
85
+ }
86
+ /** Las cuatro reglas, en orden. Conservadoras: ante la duda, el plan actual. */
87
+ function recommend(a) {
88
+ const weeksOfData = a.daysWithData / 7;
89
+ const capsPerWeek = weeksOfData > 0 ? a.fiveHour.caps / weeksOfData : 0;
90
+ if (capsPerWeek >= TIGHT_CAPS_PER_WEEK || a.week.weeksAtOrAbove90 > 0) {
91
+ const higher = a.alternatives
92
+ .filter((x) => x.direction === "higher")
93
+ .sort((x, y) => x.plan.sessionMultiplier - y.plan.sessionMultiplier)[0];
94
+ return { kind: "tight", higher: higher?.plan ?? null, capsAvoided: higher ? higher.capsAvoided : null };
95
+ }
96
+ const lower = a.alternatives
97
+ .filter((x) => x.direction === "lower")
98
+ .sort((x, y) => x.plan.monthlyUsd - y.plan.monthlyUsd);
99
+ const enough = lower.find((x) => x.blockedWindows === 0 && x.p90Equivalent <= ENOUGH_P90);
100
+ if (enough)
101
+ return { kind: "enough", plan: enough.plan, weeks: Math.max(1, Math.round(weeksOfData)) };
102
+ const almost = lower.find((x) => x.blockedWindows >= 1 && x.blockedWindows <= ALMOST_MAX_BLOCKS);
103
+ if (almost)
104
+ return { kind: "almost", plan: almost.plan, blocked: almost.blockedWindows };
105
+ return { kind: "fits" };
106
+ }
107
+ // ---------------------------------------------------------------------------
108
+ /**
109
+ * Una ventana por `resets_at`, redondeado al minuto: dos sesiones pueden
110
+ * mandar el mismo reinicio con segundos distintos. Una muestra sin reinicio
111
+ * no se puede asignar a ninguna ventana y no cuenta.
112
+ */
113
+ function fiveHourWindows(samples, now) {
114
+ const groups = new Map();
115
+ for (const s of samples) {
116
+ if (s.w5 === undefined || s.w5_reset === undefined)
117
+ continue;
118
+ const key = Math.round(s.w5_reset / 60);
119
+ const g = groups.get(key);
120
+ if (g)
121
+ g.push(s);
122
+ else
123
+ groups.set(key, [s]);
124
+ }
125
+ return [...groups.entries()].sort((a, b) => a[0] - b[0]).map(([key, g]) => {
126
+ const peak = Math.max(...g.map((s) => s.w5));
127
+ const capped = peak >= exports.CAP_AT;
128
+ const first99 = g.filter((s) => s.w5 >= STOPPED_FROM).map((s) => s.ts).sort((a, b) => a - b)[0];
129
+ const end = Math.min(key * 60_000, now.getTime());
130
+ return {
131
+ reset: key * 60, peak, samples: g.length, capped,
132
+ stoppedMs: capped && first99 !== undefined ? Math.max(0, end - first99) : 0,
133
+ };
134
+ });
135
+ }
136
+ function weekWindows(samples) {
137
+ const peaks = new Map();
138
+ for (const s of samples) {
139
+ if (s.w7 === undefined || s.w7_reset === undefined)
140
+ continue;
141
+ const key = Math.round(s.w7_reset / 60);
142
+ peaks.set(key, Math.max(peaks.get(key) ?? -Infinity, s.w7));
143
+ }
144
+ return [...peaks.entries()].sort((a, b) => a[0] - b[0]).map(([key, peak]) => ({ reset: key * 60, peak }));
145
+ }
146
+ /** Dos sesiones a la vez pueden escribir la misma muestra. */
147
+ function dedupe(samples) {
148
+ const seen = new Set();
149
+ return samples.filter((s) => {
150
+ const k = `${s.ts}|${s.w5}|${s.w5_reset}|${s.w7}|${s.w7_reset}`;
151
+ if (seen.has(k))
152
+ return false;
153
+ seen.add(k);
154
+ return true;
155
+ });
156
+ }
157
+ function median(values) {
158
+ if (values.length === 0)
159
+ return null;
160
+ const v = [...values].sort((a, b) => a - b);
161
+ const mid = Math.floor(v.length / 2);
162
+ return v.length % 2 ? v[mid] : (v[mid - 1] + v[mid]) / 2;
163
+ }
164
+ /** Percentil 90 por rango más cercano: siempre es un valor observado. */
165
+ function p90(values) {
166
+ if (values.length === 0)
167
+ return null;
168
+ const v = [...values].sort((a, b) => a - b);
169
+ return v[Math.ceil(0.9 * v.length) - 1];
170
+ }
171
+ function defaultLocalDay(ms) {
172
+ const d = new Date(ms);
173
+ return `${d.getFullYear()}-${d.getMonth() + 1}-${d.getDate()}`;
174
+ }
175
+ //# sourceMappingURL=plan.js.map