estela 0.2.4 → 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
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
@@ -2466,7 +2575,10 @@ async function main() {
2466
2575
  (0, client_js_1.setClientVersion)(VERSION);
2467
2576
  const dbPath = str(args, "db") ?? schema_js_1.DEFAULT_DB_PATH;
2468
2577
  // `estela unpublish <panel>` lleva el panel suelto, sin flag: no es un subcomando.
2469
- 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(" ");
2470
2582
  // Se llama aquí, no al final: un comando que falla a medias (por ejemplo
2471
2583
  // "estela import" sin repositorios) debe avisar igual de que hay una
2472
2584
  // versión nueva, no solo los que terminan bien.
@@ -2553,6 +2665,12 @@ async function main() {
2553
2665
  case "unpublish":
2554
2666
  await cmdUnpublish(args, dbPath);
2555
2667
  break;
2668
+ case "plan":
2669
+ cmdPlan(args, dbPath);
2670
+ break;
2671
+ case "plan set":
2672
+ cmdPlanSet(args, dbPath);
2673
+ break;
2556
2674
  case "export":
2557
2675
  await cmdExport(args, dbPath);
2558
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
@@ -579,5 +579,50 @@ exports.EN = {
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
580
  // ── statusline install: VS Code ──
581
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}",
582
627
  };
583
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
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Los bloqueos que Claude Code dejó escritos en sus transcripts.
3
+ *
4
+ * Los transcripts no traen los porcentajes de límite, pero cuando Claude Code
5
+ * recibe un 429 guarda la respuesta con un bloque `quotaLimits`: qué ventana
6
+ * fue (`five_hour` o `seven_day`) y cuándo se reinicia. Es un dato oficial y
7
+ * llega a antes de la 0.2.4, así que `estela plan` lo enseña como una línea
8
+ * aparte. No entra en el cálculo de equivalencias, que es solo de porcentajes.
9
+ *
10
+ * Los transcripts de un mes pueden pesar cientos de MB: se leen una vez y
11
+ * después solo lo nuevo de cada fichero, con lo visto guardado al lado de la
12
+ * base (`plan-blocks.json`).
13
+ */
14
+ export interface RateLimitBlock {
15
+ readonly type: "five_hour" | "seven_day";
16
+ /** `resetsAt` (epoch en s). */
17
+ readonly reset: number;
18
+ /** El primer 429 de esa ventana (epoch en ms). */
19
+ readonly at: number;
20
+ }
21
+ export declare function blocksCachePath(dbPath: string): string;
22
+ /** Lee lo nuevo de los transcripts y devuelve todos los bloqueos conocidos. */
23
+ export declare function scanBlocks(root: string, cacheFile: string, now: Date): RateLimitBlock[];
24
+ /** Los bloqueos de un texto JSONL. Solo se parsean las líneas que traen `quotaLimits`. */
25
+ export declare function blocksIn(text: string): RateLimitBlock[];
26
+ //# sourceMappingURL=plan-blocks.d.ts.map
@@ -0,0 +1,110 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.blocksCachePath = blocksCachePath;
4
+ exports.scanBlocks = scanBlocks;
5
+ exports.blocksIn = blocksIn;
6
+ const node_fs_1 = require("node:fs");
7
+ const node_path_1 = require("node:path");
8
+ const MAX_AGE_MS = 120 * 86_400_000;
9
+ function blocksCachePath(dbPath) {
10
+ return (0, node_path_1.join)((0, node_path_1.dirname)(dbPath), "plan-blocks.json");
11
+ }
12
+ /** Lee lo nuevo de los transcripts y devuelve todos los bloqueos conocidos. */
13
+ function scanBlocks(root, cacheFile, now) {
14
+ let cache = { files: {}, blocks: [] };
15
+ try {
16
+ cache = JSON.parse((0, node_fs_1.readFileSync)(cacheFile, "utf8"));
17
+ }
18
+ catch { /* primera vez */ }
19
+ const byKey = new Map(cache.blocks.map((b) => [`${b.type}|${b.reset}`, b]));
20
+ for (const file of jsonlFiles(root, now.getTime() - MAX_AGE_MS)) {
21
+ let size;
22
+ try {
23
+ size = (0, node_fs_1.statSync)(file).size;
24
+ }
25
+ catch {
26
+ continue;
27
+ }
28
+ const offset = cache.files[file] ?? 0;
29
+ const start = offset <= size ? offset : 0;
30
+ if (start >= size)
31
+ continue;
32
+ const { text, end } = readFrom(file, start, size);
33
+ for (const b of blocksIn(text)) {
34
+ const key = `${b.type}|${b.reset}`;
35
+ const prev = byKey.get(key);
36
+ if (!prev || b.at < prev.at)
37
+ byKey.set(key, b);
38
+ }
39
+ cache.files[file] = end;
40
+ }
41
+ const blocks = [...byKey.values()].filter((b) => b.at >= now.getTime() - MAX_AGE_MS).sort((a, b) => a.at - b.at);
42
+ try {
43
+ (0, node_fs_1.writeFileSync)(cacheFile, JSON.stringify({ files: cache.files, blocks }));
44
+ }
45
+ catch { /* sin caché se relee */ }
46
+ return blocks;
47
+ }
48
+ /** Los bloqueos de un texto JSONL. Solo se parsean las líneas que traen `quotaLimits`. */
49
+ function blocksIn(text) {
50
+ const out = [];
51
+ let i = text.indexOf('"quotaLimits"');
52
+ while (i >= 0) {
53
+ const startLine = text.lastIndexOf("\n", i) + 1;
54
+ const endLine = text.indexOf("\n", i);
55
+ const line = text.slice(startLine, endLine < 0 ? text.length : endLine);
56
+ try {
57
+ const d = JSON.parse(line);
58
+ const q = d["quotaLimits"];
59
+ const type = q?.["rateLimitType"];
60
+ const reset = q?.["resetsAt"];
61
+ const at = Date.parse(String(d["timestamp"]));
62
+ if ((type === "five_hour" || type === "seven_day") && typeof reset === "number" && !Number.isNaN(at) &&
63
+ (d["apiErrorStatus"] === 429 || q?.["status"] === "rejected")) {
64
+ out.push({ type, reset, at });
65
+ }
66
+ }
67
+ catch { /* línea rota o a medias */ }
68
+ i = endLine < 0 ? -1 : text.indexOf('"quotaLimits"', endLine);
69
+ }
70
+ return out;
71
+ }
72
+ /** Hasta el último salto de línea: una línea a medias se lee entera la próxima vez. */
73
+ function readFrom(file, start, size) {
74
+ const fd = (0, node_fs_1.openSync)(file, "r");
75
+ try {
76
+ const buf = Buffer.alloc(size - start);
77
+ (0, node_fs_1.readSync)(fd, buf, 0, buf.length, start);
78
+ const nl = buf.lastIndexOf(0x0a);
79
+ if (nl < 0)
80
+ return { text: "", end: start };
81
+ return { text: buf.subarray(0, nl + 1).toString("utf8"), end: start + nl + 1 };
82
+ }
83
+ finally {
84
+ (0, node_fs_1.closeSync)(fd);
85
+ }
86
+ }
87
+ function jsonlFiles(dir, modifiedAfter) {
88
+ const out = [];
89
+ let entries;
90
+ try {
91
+ entries = (0, node_fs_1.readdirSync)(dir, { withFileTypes: true });
92
+ }
93
+ catch {
94
+ return out;
95
+ }
96
+ for (const e of entries) {
97
+ const p = (0, node_path_1.join)(dir, e.name);
98
+ if (e.isDirectory())
99
+ out.push(...jsonlFiles(p, modifiedAfter));
100
+ else if (e.name.endsWith(".jsonl")) {
101
+ try {
102
+ if ((0, node_fs_1.statSync)(p).mtimeMs >= modifiedAfter)
103
+ out.push(p);
104
+ }
105
+ catch { /* se borró mientras tanto */ }
106
+ }
107
+ }
108
+ return out;
109
+ }
110
+ //# sourceMappingURL=plan-blocks.js.map
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Los planes de Claude que analiza `estela plan`: lo único que Estela sabe de
3
+ * ellos, en un solo sitio y con su fuente.
4
+ *
5
+ * - El multiplicador por sesión de 5 h es el que publica Anthropic: Max 5x da
6
+ * 5 veces la cuota por sesión de Pro, y Max 20x, 20 veces.
7
+ * - Del límite semanal Anthropic no publica la relación entre planes, así que
8
+ * aquí no hay ningún número para él, y no se compara.
9
+ * - Los precios son los de la suscripción web, en USD al mes, y caducan:
10
+ * `estela plan` avisa si la fecha de comprobación tiene más de 90 días.
11
+ */
12
+ export type ClaudePlanId = "pro" | "max5" | "max20";
13
+ export interface ClaudePlan {
14
+ readonly id: ClaudePlanId;
15
+ readonly name: string;
16
+ /** Veces la cuota por sesión de 5 h de Pro. */
17
+ readonly sessionMultiplier: number;
18
+ readonly monthlyUsd: number;
19
+ }
20
+ export declare const CLAUDE_PLANS: readonly ClaudePlan[];
21
+ export declare const MULTIPLIER_SOURCE = "https://support.claude.com/en/articles/11049741-what-is-the-max-plan";
22
+ export declare const PRICES_SOURCE = "https://claude.com/pricing";
23
+ /** Cuándo se comprobaron los precios en `PRICES_SOURCE`. */
24
+ export declare const PRICES_CHECKED_AT = "2026-10-19";
25
+ export declare const PRICES_STALE_DAYS = 90;
26
+ export declare function planById(id: string): ClaudePlan | null;
27
+ //# sourceMappingURL=plan-prices.d.ts.map
@@ -0,0 +1,29 @@
1
+ "use strict";
2
+ /**
3
+ * Los planes de Claude que analiza `estela plan`: lo único que Estela sabe de
4
+ * ellos, en un solo sitio y con su fuente.
5
+ *
6
+ * - El multiplicador por sesión de 5 h es el que publica Anthropic: Max 5x da
7
+ * 5 veces la cuota por sesión de Pro, y Max 20x, 20 veces.
8
+ * - Del límite semanal Anthropic no publica la relación entre planes, así que
9
+ * aquí no hay ningún número para él, y no se compara.
10
+ * - Los precios son los de la suscripción web, en USD al mes, y caducan:
11
+ * `estela plan` avisa si la fecha de comprobación tiene más de 90 días.
12
+ */
13
+ Object.defineProperty(exports, "__esModule", { value: true });
14
+ exports.PRICES_STALE_DAYS = exports.PRICES_CHECKED_AT = exports.PRICES_SOURCE = exports.MULTIPLIER_SOURCE = exports.CLAUDE_PLANS = void 0;
15
+ exports.planById = planById;
16
+ exports.CLAUDE_PLANS = [
17
+ { id: "pro", name: "Pro", sessionMultiplier: 1, monthlyUsd: 20 },
18
+ { id: "max5", name: "Max 5x", sessionMultiplier: 5, monthlyUsd: 100 },
19
+ { id: "max20", name: "Max 20x", sessionMultiplier: 20, monthlyUsd: 200 },
20
+ ];
21
+ exports.MULTIPLIER_SOURCE = "https://support.claude.com/en/articles/11049741-what-is-the-max-plan";
22
+ exports.PRICES_SOURCE = "https://claude.com/pricing";
23
+ /** Cuándo se comprobaron los precios en `PRICES_SOURCE`. */
24
+ exports.PRICES_CHECKED_AT = "2026-10-19";
25
+ exports.PRICES_STALE_DAYS = 90;
26
+ function planById(id) {
27
+ return exports.CLAUDE_PLANS.find((p) => p.id === id) ?? null;
28
+ }
29
+ //# sourceMappingURL=plan-prices.js.map
@@ -0,0 +1,96 @@
1
+ import type { PlanAnalysis } from "./metrics/plan.js";
2
+ /**
3
+ * Lo que enseña `estela plan`: el texto (es/en) y el JSON.
4
+ *
5
+ * Aparte de cli.ts para poder comprobar cada frase: ninguna puede decir
6
+ * cuánto te queda ni hablar de tokens. Lo que hay son los porcentajes que
7
+ * mandó Claude Code y las equivalencias por el multiplicador publicado.
8
+ */
9
+ export interface BlockCounts {
10
+ readonly fiveHour: number;
11
+ readonly sevenDay: number;
12
+ }
13
+ export interface ReportOptions {
14
+ readonly color: boolean;
15
+ readonly now: Date;
16
+ /** Bloqueos 429 de los transcripts en el período, si se pudieron leer. */
17
+ readonly blocks: BlockCounts | null;
18
+ }
19
+ export declare function renderPlanReport(a: PlanAnalysis, o: ReportOptions): string;
20
+ export declare function recommendationText(a: PlanAnalysis): string;
21
+ export declare function pricesStale(now: Date): boolean;
22
+ /** Sin plan declarado: cómo declararlo. */
23
+ export declare function renderNoPlan(): string;
24
+ export declare function renderNoData(a: PlanAnalysis, apiKey: boolean, color: boolean): string;
25
+ /**
26
+ * `estela plan --json`. Estructura estable (documentada en el README):
27
+ * campos nuevos se pueden añadir, ninguno cambia de nombre ni de significado.
28
+ */
29
+ export declare function planToJson(a: PlanAnalysis, o: {
30
+ readonly blocks: BlockCounts | null;
31
+ readonly now: Date;
32
+ }): {
33
+ version: number;
34
+ plan: {
35
+ id: import("./plan-prices.js").ClaudePlanId;
36
+ name: string;
37
+ since: string;
38
+ sessionMultiplier: number;
39
+ };
40
+ period: {
41
+ from: string;
42
+ to: string;
43
+ days: number;
44
+ daysWithData: number;
45
+ samples: number;
46
+ };
47
+ dataState: import("./metrics/plan.js").DataState;
48
+ fiveHour: {
49
+ windows: number;
50
+ medianPeak: number | null;
51
+ p90Peak: number | null;
52
+ maxPeak: number | null;
53
+ caps: number;
54
+ stoppedMinutesUpperBound: number;
55
+ };
56
+ week: {
57
+ weeks: number;
58
+ medianPeak: number | null;
59
+ maxPeak: number | null;
60
+ weeksAtOrAbove90: number;
61
+ };
62
+ blocks429: BlockCounts | null;
63
+ alternatives: {
64
+ plan: import("./plan-prices.js").ClaudePlanId;
65
+ name: string;
66
+ direction: "lower" | "higher";
67
+ monthlyUsd: number;
68
+ monthlyDiffUsd: number;
69
+ p90Equivalent: number | null;
70
+ maxEquivalent: number | null;
71
+ blockedWindows: number;
72
+ capsAvoided: number;
73
+ }[];
74
+ recommendation: {
75
+ kind: "tight" | "enough" | "almost" | "fits";
76
+ provisional: boolean;
77
+ plan: import("./plan-prices.js").ClaudePlanId | null;
78
+ blockedWindows: number | null;
79
+ capsAvoided: number | null;
80
+ text: string;
81
+ } | null;
82
+ weeklyBetweenPlans: string;
83
+ sources: {
84
+ sessionMultiplier: string;
85
+ prices: string;
86
+ pricesCheckedAt: string;
87
+ pricesStale: boolean;
88
+ };
89
+ plans: {
90
+ id: import("./plan-prices.js").ClaudePlanId;
91
+ name: string;
92
+ sessionMultiplier: number;
93
+ monthlyUsd: number;
94
+ }[];
95
+ };
96
+ //# sourceMappingURL=plan-report.d.ts.map
@@ -0,0 +1,190 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.renderPlanReport = renderPlanReport;
4
+ exports.recommendationText = recommendationText;
5
+ exports.pricesStale = pricesStale;
6
+ exports.renderNoPlan = renderNoPlan;
7
+ exports.renderNoData = renderNoData;
8
+ exports.planToJson = planToJson;
9
+ const index_js_1 = require("./i18n/index.js");
10
+ const plan_prices_js_1 = require("./plan-prices.js");
11
+ const statusline_js_1 = require("./statusline.js");
12
+ const ESC = "\x1b[";
13
+ const JADE = `${ESC}38;2;70;192;167m`; // #46C0A7, como en la status line
14
+ const AMBER = `${ESC}38;2;221;168;90m`;
15
+ const DIM = `${ESC}90m`;
16
+ const RESET = `${ESC}0m`;
17
+ const LABEL = 21;
18
+ function renderPlanReport(a, o) {
19
+ const paint = (code, text) => (o.color ? `${code}${text}${RESET}` : text);
20
+ const row = (label, value) => `${label.padEnd(LABEL)}${value}`;
21
+ const out = [];
22
+ out.push(header(a, paint));
23
+ out.push("");
24
+ const f = a.fiveHour;
25
+ out.push(row((0, index_js_1.tr) `Ventanas de 5 h`, f.windows.length === 0
26
+ ? (0, index_js_1.tr) `ninguna completa todavía`
27
+ : (0, index_js_1.tr) `${f.windows.length} ventanas · pico mediano ${pct(f.medianPeak)} % · p90 ${pct(f.p90Peak)} % · máximo ${pct(f.maxPeak)} %`));
28
+ out.push(row((0, index_js_1.tr) `Topes (≥95 %)`, String(f.caps)));
29
+ if (f.stoppedMs > 0) {
30
+ out.push(row((0, index_js_1.tr) `Parado tras topes`, (0, index_js_1.tr) `hasta ${(0, statusline_js_1.compactDuration)(f.stoppedMs / 1000)} (estimado: no sabemos si seguías trabajando)`));
31
+ }
32
+ const w = a.week;
33
+ out.push(row((0, index_js_1.tr) `Semana`, w.weeks.length === 0
34
+ ? (0, index_js_1.tr) `sin datos de la semana`
35
+ : (0, index_js_1.tr) `pico mediano ${pct(w.medianPeak)} % · máximo ${pct(w.maxPeak)} % · ${w.weeksAtOrAbove90} semanas ≥ 90 %`));
36
+ if (o.blocks) {
37
+ out.push(row((0, index_js_1.tr) `Bloqueos (429)`, (0, index_js_1.tr) `${o.blocks.fiveHour} de 5 h · ${o.blocks.sevenDay} semanales, según los transcripts de Claude Code`));
38
+ }
39
+ if (f.windows.length > 0) {
40
+ out.push("");
41
+ out.push((0, index_js_1.tr) `Si estuvieras en…`);
42
+ for (const alt of [...a.alternatives].sort((x, y) => x.plan.monthlyUsd - y.plan.monthlyUsd)) {
43
+ out.push(` ${`${alt.plan.name} (${usd(alt.plan.monthlyUsd)})`.padEnd(LABEL - 2)}${alternativeText(alt, a)}`);
44
+ }
45
+ }
46
+ out.push("");
47
+ if (a.dataState === "insufficient") {
48
+ out.push(paint(AMBER, (0, index_js_1.tr) `Datos de ${a.daysWithData} días. La recomendación necesita al menos 14.`));
49
+ }
50
+ else if (a.recommendation) {
51
+ const label = a.dataState === "provisional"
52
+ ? (0, index_js_1.tr) `Recomendación (provisional, ${a.daysWithData} días)`
53
+ : (0, index_js_1.tr) `Recomendación`;
54
+ const text = recommendationText(a);
55
+ out.push(row(label, paint(a.recommendation.kind === "tight" ? AMBER : JADE, text)));
56
+ const diff = priceDiffText(a);
57
+ if (diff)
58
+ out.push(row("", paint(DIM, diff)));
59
+ }
60
+ out.push("");
61
+ out.push((0, index_js_1.tr) `Límite semanal entre planes: Anthropic no publica la relación. No lo estimamos.`);
62
+ out.push((0, index_js_1.tr) `Basado en el multiplicador por sesión publicado por Anthropic:`);
63
+ out.push(plan_prices_js_1.MULTIPLIER_SOURCE);
64
+ out.push((0, index_js_1.tr) `Anthropic puede cambiar los límites; repite el análisis cada mes.`);
65
+ out.push((0, index_js_1.tr) `Precios según la web de Anthropic a ${plan_prices_js_1.PRICES_CHECKED_AT}; compruébalo: ${plan_prices_js_1.PRICES_SOURCE}`);
66
+ if (pricesStale(o.now))
67
+ out.push(paint(AMBER, (0, index_js_1.tr) `Esa fecha tiene más de ${plan_prices_js_1.PRICES_STALE_DAYS} días: los precios pueden estar desactualizados.`));
68
+ return out.join("\n");
69
+ }
70
+ function header(a, paint) {
71
+ return `${paint(JADE, "estela")} plan · ${a.plan.name} ${(0, index_js_1.tr) `(desde ${day(a.since)})`} · ${(0, index_js_1.tr) `${a.daysWithData} días con datos`}`;
72
+ }
73
+ function alternativeText(alt, a) {
74
+ const n = a.fiveHour.windows.length;
75
+ if (alt.direction === "higher") {
76
+ if (a.fiveHour.caps === 0)
77
+ return (0, index_js_1.tr) `sin topes, igual que ahora`;
78
+ return (0, index_js_1.tr) `p90 equivalente ${pct(alt.p90Equivalent)} % · habrías evitado ${alt.capsAvoided} de ${a.fiveHour.caps} topes (con el uso que llegó a registrarse)`;
79
+ }
80
+ return alt.blockedWindows === 0
81
+ ? (0, index_js_1.tr) `p90 equivalente ${pct(alt.p90Equivalent)} % · no te habría bloqueado en ninguna de las ${n} ventanas`
82
+ : (0, index_js_1.tr) `p90 equivalente ${pct(alt.p90Equivalent)} % · te habría bloqueado en ${alt.blockedWindows} de ${n} ventanas`;
83
+ }
84
+ function recommendationText(a) {
85
+ const r = a.recommendation;
86
+ if (!r)
87
+ return "";
88
+ switch (r.kind) {
89
+ case "tight":
90
+ return r.higher && r.capsAvoided !== null
91
+ ? (0, index_js_1.tr) `Vas justo con tu plan. Con ${r.higher.name} habrías evitado ${r.capsAvoided} de ${a.fiveHour.caps} topes.`
92
+ : (0, index_js_1.tr) `Vas justo con tu plan.`;
93
+ case "enough":
94
+ return (0, index_js_1.tr) `Con los datos de estas ${r.weeks} semanas, el plan ${r.plan.name} te habría bastado en todas las ventanas de 5 h.`;
95
+ case "almost":
96
+ return (0, index_js_1.tr) `El plan ${r.plan.name} te habría bastado casi siempre: te habría bloqueado ${r.blocked} veces en estas semanas.`;
97
+ case "fits":
98
+ return (0, index_js_1.tr) `Tu plan actual encaja con tu uso.`;
99
+ }
100
+ }
101
+ /** La diferencia de precio de lo que se recomienda, si es cambiar de plan. */
102
+ function priceDiffText(a) {
103
+ const r = a.recommendation;
104
+ const other = r?.kind === "enough" || r?.kind === "almost" ? r.plan
105
+ : r?.kind === "tight" ? r.higher : null;
106
+ if (!other)
107
+ return null;
108
+ const diff = other.monthlyUsd - a.plan.monthlyUsd;
109
+ return diff < 0
110
+ ? (0, index_js_1.tr) `${usd(-diff)} menos al mes, según la web de Anthropic a ${plan_prices_js_1.PRICES_CHECKED_AT}; compruébalo.`
111
+ : (0, index_js_1.tr) `${usd(diff)} más al mes, según la web de Anthropic a ${plan_prices_js_1.PRICES_CHECKED_AT}; compruébalo.`;
112
+ }
113
+ function pricesStale(now) {
114
+ return now.getTime() - Date.parse(`${plan_prices_js_1.PRICES_CHECKED_AT}T00:00:00Z`) > plan_prices_js_1.PRICES_STALE_DAYS * 86_400_000;
115
+ }
116
+ /** Sin plan declarado: cómo declararlo. */
117
+ function renderNoPlan() {
118
+ return [
119
+ (0, index_js_1.tr) `estela plan compara tu uso real de Claude con los otros planes. Primero dime cuál pagas:`,
120
+ "",
121
+ " estela plan set pro",
122
+ " estela plan set max5",
123
+ " estela plan set max20",
124
+ "",
125
+ (0, index_js_1.tr) `Si ya lo tenías antes de hoy, añade desde cuándo: --since AAAA-MM-DD`,
126
+ ].join("\n");
127
+ }
128
+ function renderNoData(a, apiKey, color) {
129
+ const paint = (code, text) => (color ? `${code}${text}${RESET}` : text);
130
+ return [
131
+ header(a, paint), "",
132
+ apiKey
133
+ ? (0, index_js_1.tr) `Este análisis solo funciona con planes Pro o Max.`
134
+ : (0, index_js_1.tr) `Aún no hay datos. Instala la status line (estela statusline install) y vuelve en unos días.`,
135
+ ...(apiKey ? [] : [(0, index_js_1.tr) `Solo se recogen en Claude Code en la terminal: la extensión de VS Code no ejecuta la status line.`]),
136
+ ].join("\n");
137
+ }
138
+ // ---------------------------------------------------------------------------
139
+ /**
140
+ * `estela plan --json`. Estructura estable (documentada en el README):
141
+ * campos nuevos se pueden añadir, ninguno cambia de nombre ni de significado.
142
+ */
143
+ function planToJson(a, o) {
144
+ const r1 = (n) => (n === null ? null : Math.round(n * 10) / 10);
145
+ const rec = a.recommendation;
146
+ return {
147
+ version: 1,
148
+ plan: { id: a.plan.id, name: a.plan.name, since: a.since.toISOString(), sessionMultiplier: a.plan.sessionMultiplier },
149
+ period: { from: a.from.toISOString(), to: a.to.toISOString(), days: a.days, daysWithData: a.daysWithData, samples: a.samples },
150
+ dataState: a.dataState,
151
+ fiveHour: {
152
+ windows: a.fiveHour.windows.length,
153
+ medianPeak: r1(a.fiveHour.medianPeak), p90Peak: r1(a.fiveHour.p90Peak), maxPeak: r1(a.fiveHour.maxPeak),
154
+ caps: a.fiveHour.caps,
155
+ stoppedMinutesUpperBound: Math.round(a.fiveHour.stoppedMs / 60_000),
156
+ },
157
+ week: {
158
+ weeks: a.week.weeks.length,
159
+ medianPeak: r1(a.week.medianPeak), maxPeak: r1(a.week.maxPeak),
160
+ weeksAtOrAbove90: a.week.weeksAtOrAbove90,
161
+ },
162
+ blocks429: o.blocks,
163
+ alternatives: [...a.alternatives].sort((x, y) => x.plan.monthlyUsd - y.plan.monthlyUsd).map((x) => ({
164
+ plan: x.plan.id, name: x.plan.name, direction: x.direction,
165
+ monthlyUsd: x.plan.monthlyUsd, monthlyDiffUsd: x.plan.monthlyUsd - a.plan.monthlyUsd,
166
+ p90Equivalent: r1(x.p90Equivalent), maxEquivalent: r1(x.maxEquivalent),
167
+ blockedWindows: x.blockedWindows, capsAvoided: x.capsAvoided,
168
+ })),
169
+ recommendation: rec === null ? null : {
170
+ kind: rec.kind,
171
+ provisional: a.dataState === "provisional",
172
+ plan: rec.kind === "enough" || rec.kind === "almost" ? rec.plan.id : rec.kind === "tight" ? rec.higher?.id ?? null : null,
173
+ blockedWindows: rec.kind === "almost" ? rec.blocked : null,
174
+ capsAvoided: rec.kind === "tight" ? rec.capsAvoided : null,
175
+ text: recommendationText(a),
176
+ },
177
+ weeklyBetweenPlans: "not_published",
178
+ sources: {
179
+ sessionMultiplier: plan_prices_js_1.MULTIPLIER_SOURCE,
180
+ prices: plan_prices_js_1.PRICES_SOURCE, pricesCheckedAt: plan_prices_js_1.PRICES_CHECKED_AT, pricesStale: pricesStale(o.now),
181
+ },
182
+ plans: plan_prices_js_1.CLAUDE_PLANS.map((p) => ({ id: p.id, name: p.name, sessionMultiplier: p.sessionMultiplier, monthlyUsd: p.monthlyUsd })),
183
+ };
184
+ }
185
+ function pct(n) { return Math.round(n); }
186
+ function usd(n) { return (0, index_js_1.tr) `${n} $`; }
187
+ function day(d) {
188
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
189
+ }
190
+ //# sourceMappingURL=plan-report.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "estela",
3
- "version": "0.2.4",
3
+ "version": "0.3.0",
4
4
  "description": "Time tracking you never fill in: rebuilds your billable hours from Claude Code, Codex and GitHub Copilot sessions and your Git history, and splits what the AI cost per project.",
5
5
  "keywords": [
6
6
  "time-tracking",