agy-insights 0.3.0 → 0.7.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/lib/report.js CHANGED
@@ -1,9 +1,15 @@
1
1
  // Renders the analysis as a self-contained HTML page (opened in the browser) or as plain text.
2
- // The page is static: its only interaction is copying text, so it cannot change anything on disk.
2
+ // The page is static: its only interactions are switching the period, the chart tooltip and copying text,
3
+ // so it cannot change anything on disk.
4
+
5
+ const { t, lang, num, date, kindLabel, effortLabel } = require('./i18n');
6
+ const { dayKey } = require('./history');
3
7
 
4
8
  const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
5
- const fmt = (n) => Math.round(n).toLocaleString('es-ES');
9
+ const fmt = num;
6
10
  const pct = (a, b) => (b ? `${Math.round((100 * a) / b)}%` : '–');
11
+ // "Period: from 3 Oct 2026 to 29 Oct 2026 (filter: active since 1 Oct 2026)", or '' with no conversations.
12
+ const periodLine = (r) => (r.period ? t.report.periodLine(t.report.period(date(r.period.from), date(r.period.to)), r.since && t.report.since(date(r.since))) : '');
7
13
  const label = (x) => (x.project ? `${x.project} › ${x.rel}` : x.abs || x.key);
8
14
 
9
15
  // Real usage, from agy's transcripts: what counts against the quota, unlike the size estimate.
@@ -14,19 +20,19 @@ const withCache = (u) => u.input + u.cache + u.output;
14
20
 
15
21
  function fixBlock(f) {
16
22
  return `<div class="fix${f.applied ? ' done' : ''}">
17
- <div class="fix-head"><b>${esc(f.title)}</b>${f.applied ? '<span class="pill">ya aplicado</span>' : ''}</div>
18
- <p class="where">En <code>${esc(f.file)}</code>, ${esc(f.where)}:</p>
19
- <div class="code"><button class="copy" type="button">Copiar</button><pre>${esc(f.text)}</pre></div>
23
+ <div class="fix-head"><b>${esc(f.title)}</b>${f.applied ? `<span class="pill">${t.report.applied}</span>` : ''}</div>
24
+ <p class="where">${t.report.in} <code>${esc(f.file)}</code>, ${esc(f.where)}:</p>
25
+ <div class="code"><button class="copy" type="button">${t.report.copy}</button><pre>${esc(f.text)}</pre></div>
20
26
  <p class="hint">${esc(f.note)}</p>
21
27
  </div>`;
22
28
  }
23
29
 
24
30
  function item(it) {
25
31
  const extra = it.fixes.length
26
- ? `<details><summary>Cómo excluirlo</summary>${it.fixes.map(fixBlock).join('')}</details>`
32
+ ? `<details><summary>${t.report.howToExclude}</summary>${it.fixes.map(fixBlock).join('')}</details>`
27
33
  : it.example
28
- ? `<details><summary>Regla de ejemplo para AGENTS.md</summary><div class="fix"><div class="code"><button class="copy" type="button">Copiar</button><pre>${esc(it.example)}</pre></div>
29
- <p class="hint">Ponla en el AGENTS.md del proyecto solo si te pasa a menudo: las reglas se envían en cada petición.</p></div></details>`
34
+ ? `<details><summary>${t.report.exampleRule}</summary><div class="fix"><div class="code"><button class="copy" type="button">${t.report.copy}</button><pre>${esc(it.example)}</pre></div>
35
+ <p class="hint">${t.report.exampleHint}</p></div></details>`
30
36
  : '';
31
37
  return `<li><div class="row"><div class="what"><span class="label">${esc(it.label)}</span><span class="detail">${esc(it.detail)}</span></div>
32
38
  <span class="num">${it.tokens ? `~${fmt(it.tokens)}` : ''}</span></div>${extra}</li>`;
@@ -43,7 +49,7 @@ function finding(f, total) {
43
49
  function table(title, rows, total, name = label) {
44
50
  if (!rows.length) return '';
45
51
  const max = Math.max(...rows.map((r) => r.tokens), 1);
46
- return `<h3>${esc(title)}</h3><div class="scroll"><table><thead><tr><th>Qué</th><th class="num">~tokens</th><th class="num">%</th><th class="num">Veces</th><th class="num">Sesiones</th></tr></thead><tbody>
52
+ return `<h3>${esc(title)}</h3><div class="scroll"><table><thead><tr>${t.report.cols.map((c, i) => `<th${i ? ' class="num"' : ''}>${c}</th>`).join('')}</tr></thead><tbody>
47
53
  ${rows.slice(0, 15).map((r) => `<tr><td><div class="bar" style="--w:${((100 * r.tokens) / max).toFixed(1)}%"></div><span>${esc(name(r))}</span></td>
48
54
  <td class="num">${fmt(r.tokens)}</td><td class="num">${pct(r.tokens, total)}</td><td class="num">${r.calls}</td><td class="num">${r.convs || ''}</td></tr>`).join('')}
49
55
  </tbody></table></div>`;
@@ -53,55 +59,176 @@ function sessionsTable(r) {
53
59
  const rows = r.sessions.filter((s) => s.usage).slice(0, 15);
54
60
  if (!rows.length) return '';
55
61
  const max = Math.max(...rows.map((s) => billed(s.usage)), 1);
56
- return `<h3>Sesiones (consumo real)</h3><div class="scroll"><table><thead><tr><th>Conversación</th><th class="num">Tokens</th><th class="num">%</th><th class="num">Con caché</th><th class="num">Llamadas</th><th class="num">Errores</th></tr></thead><tbody>
57
- ${rows.map((s) => `<tr><td><div class="bar" style="--w:${((100 * billed(s.usage)) / max).toFixed(1)}%"></div><span>${esc(s.id.slice(0, 8))}</span></td>
62
+ return `<h3>${t.report.sessionsTitle}</h3><div class="scroll"><table><thead><tr>${t.report.sessionCols.map((c, i) => `<th${i > 1 ? ' class="num"' : ''}>${c}</th>`).join('')}</tr></thead><tbody>
63
+ ${rows.map((s) => `<tr><td><div class="bar" style="--w:${((100 * billed(s.usage)) / max).toFixed(1)}%"></div><span>${esc(s.id.slice(0, 8))}</span></td><td>${esc(sessionModel(s))}</td>
58
64
  <td class="num">${fmt(billed(s.usage))}</td><td class="num">${pct(billed(s.usage), billed(r.usage))}</td><td class="num">${fmt(withCache(s.usage))}</td>
59
65
  <td class="num">${s.usage.calls}</td><td class="num">${s.usage.errors || ''}</td></tr>`).join('')}
60
66
  </tbody></table></div>`;
61
67
  }
62
68
 
63
- function html(r, found, { scope }) {
69
+ // "Gemini 3.8 Flash · High", or "Gemini 3.8 Flash · High +1 more" when the session used several.
70
+ const modelName = (m) => (m.model || t.report.unknownModel);
71
+ const sessionModel = (s) => (s.model ? `${modelName(s.model)} · ${effortLabel(s.model.effort)}${s.models > 1 ? t.report.otherModels(s.models - 1) : ''}` : '');
72
+
73
+ // Calls and real tokens per model and effort level.
74
+ function modelsTable(r) {
75
+ const rows = r.models.filter((m) => m.model || m.calls);
76
+ if (!rows.length || (rows.length === 1 && !rows[0].model)) return '';
77
+ const total = rows.reduce((s, m) => s + m.input + m.output, 0);
78
+ const max = Math.max(...rows.map((m) => m.input + m.output), 1);
79
+ return `<section class="models"><h2>${t.report.modelsTitle}</h2><p class="hint">${t.report.modelsHint}</p>
80
+ <div class="scroll"><table><thead><tr>${t.report.modelCols.map((c, i) => `<th${i > 1 ? ' class="num"' : ''}>${c}</th>`).join('')}</tr></thead><tbody>
81
+ ${rows.map((m) => { const tok = m.input + m.output; return `<tr><td><div class="bar" style="--w:${((100 * tok) / max).toFixed(1)}%"></div><span>${esc(modelName(m))}</span></td>
82
+ <td>${esc(effortLabel(m.effort))}</td><td class="num">${fmt(m.calls)}</td><td class="num">${m.known ? fmt(tok) : '–'}</td><td class="num">${m.known ? pct(tok, total) : '–'}</td>
83
+ <td class="num">${m.known ? fmt(tok / m.known) : '–'}</td><td class="num">${m.known ? fmt(tok + m.cache) : '–'}</td><td class="num">${m.convs}</td></tr>`; }).join('')}
84
+ </tbody></table></div></section>`;
85
+ }
86
+
87
+ // --- Usage chart ---
88
+
89
+ const DAY = 24 * 60 * 60 * 1000;
90
+ const parseDay = (k) => { const [y, m, d] = k.split('-').map(Number); return new Date(y, m - 1, d); };
91
+ const shortDate = (d, year) => d.toLocaleDateString(t.locale, { day: 'numeric', month: 'short', ...(year ? { year: 'numeric' } : {}) });
92
+ const compact = (n) => new Intl.NumberFormat(t.locale, { notation: 'compact', maximumFractionDigits: 1 }).format(n);
93
+ // 1, 2, 2.5, 5 × 10^k: a round top for the y axis.
94
+ const niceMax = (v) => { const p = 10 ** Math.floor(Math.log10(v || 1)); return [1, 2, 2.5, 5, 10].map((m) => m * p).find((x) => x >= v); };
95
+
96
+ // Real usage per day (per week past ~4 months), continuous: days without calls are zero-height columns.
97
+ function buckets(r) {
98
+ if (!r.daily.length) return null;
99
+ const first = r.since ? parseDay(dayKey(r.since)) : parseDay(r.daily[0].day);
100
+ const last = r.since ? parseDay(dayKey(Date.now())) : parseDay(r.daily[r.daily.length - 1].day);
101
+ const weekly = (last - first) / DAY > 120;
102
+ const startOf = (d) => (weekly ? new Date(d.getFullYear(), d.getMonth(), d.getDate() - ((d.getDay() + 6) % 7)) : d);
103
+ const byKey = new Map();
104
+ for (let d = startOf(first); d <= last; d = new Date(d.getFullYear(), d.getMonth(), d.getDate() + (weekly ? 7 : 1))) {
105
+ byKey.set(dayKey(d.getTime()), { date: d, billed: 0, cache: 0, calls: 0 });
106
+ }
107
+ for (const x of r.daily) {
108
+ const b = byKey.get(dayKey(startOf(parseDay(x.day)).getTime()));
109
+ if (!b) continue;
110
+ b.billed += x.input + x.output;
111
+ b.cache += x.cache;
112
+ b.calls += x.calls;
113
+ }
114
+ return { weekly, rows: [...byKey.values()] };
115
+ }
116
+
117
+ function chart(r) {
118
+ const b = buckets(r);
119
+ if (!b) return '';
120
+ const W = 900, H = 240, m = { l: 56, r: 8, t: 12, b: 28 };
121
+ const pw = W - m.l - m.r, ph = H - m.t - m.b;
122
+ const top = niceMax(Math.max(...b.rows.map((x) => x.billed)));
123
+ const band = pw / b.rows.length;
124
+ const bw = Math.max(1, Math.min(24, band - 2));
125
+ const y = (v) => m.t + ph - (v / top) * ph;
126
+ const when = (x) => (b.weekly ? t.report.weekOf(shortDate(x.date, true)) : shortDate(x.date, true));
127
+
128
+ const grid = [0, top / 2, top].map((v) => `<line x1="${m.l}" x2="${W - m.r}" y1="${y(v)}" y2="${y(v)}" class="grid"/>
129
+ <text x="${m.l - 8}" y="${y(v) + 4}" text-anchor="end" class="axis">${esc(compact(v))}</text>`).join('');
130
+ // Rounded data end (4px), square at the baseline.
131
+ const bars = b.rows.map((x, i) => {
132
+ const h = (x.billed / top) * ph;
133
+ if (h <= 0) return '';
134
+ const x0 = m.l + i * band + (band - bw) / 2, y0 = m.t + ph - h, rr = Math.min(4, h, bw / 2);
135
+ return `<path class="col" d="M${x0},${m.t + ph}V${y0 + rr}Q${x0},${y0} ${x0 + rr},${y0}H${x0 + bw - rr}Q${x0 + bw},${y0} ${x0 + bw},${y0 + rr}V${m.t + ph}Z"/>`;
136
+ }).join('');
137
+ // Hit targets: the whole band, so a 2px column is as easy to hover as a 24px one.
138
+ const hits = b.rows.map((x, i) => `<rect class="hit" x="${m.l + i * band}" y="${m.t}" width="${band}" height="${ph}"
139
+ data-when="${esc(when(x))}" data-v="${esc(fmt(x.billed))}" data-c="${esc(fmt(x.billed + x.cache))}" data-n="${esc(t.report.chartCalls(x.calls))}"/>`).join('');
140
+ const every = Math.max(1, Math.ceil(b.rows.length / 6));
141
+ // A date every few columns, plus the last one when it isn't crowded by the previous label.
142
+ const last = b.rows.length - 1;
143
+ const ticks = b.rows.map((x, i) => (i % every === 0 || (i === last && last % every >= every / 2)) && `<text x="${i === 0 && band < 40 ? m.l : i === last && band < 40 ? W - m.r : m.l + i * band + band / 2}" y="${H - 8}" text-anchor="${i === 0 && band < 40 ? 'start' : i === last && band < 40 ? 'end' : 'middle'}" class="axis">${esc(shortDate(x.date))}</text>`).filter(Boolean).join('');
144
+
145
+ const rows = b.rows.filter((x) => x.calls);
146
+ return `<section class="chart">
147
+ <h2>${b.weekly ? t.report.chartTitleWeek : t.report.chartTitleDay}</h2>
148
+ <p class="hint">${t.report.chartHint}</p>
149
+ <div class="plot"><svg viewBox="0 0 ${W} ${H}" role="img" aria-label="${esc(b.weekly ? t.report.chartTitleWeek : t.report.chartTitleDay)}">${grid}${bars}${ticks}${hits}</svg></div>
150
+ <details><summary>${t.report.chartTable}</summary><div class="scroll"><table><thead><tr>${(b.weekly ? t.report.chartColsWeek : t.report.chartCols).map((c, i) => `<th${i ? ' class="num"' : ''}>${c}</th>`).join('')}</tr></thead><tbody>
151
+ ${rows.map((x) => `<tr><td>${esc(shortDate(x.date, true))}</td><td class="num">${fmt(x.billed)}</td><td class="num">${fmt(x.billed + x.cache)}</td><td class="num">${x.calls}</td></tr>`).join('')}
152
+ </tbody></table></div></details>
153
+ </section>`;
154
+ }
155
+
156
+ // --- Page ---
157
+
158
+ function body(r, found, scope) {
64
159
  const top5 = r.sessions.slice(0, 5).reduce((s, x) => s + x.tokens, 0);
65
160
  const u = r.usage;
66
- const body = !r.conversations
67
- ? `<p>No hay conversaciones${scope ? ` de <code>${esc(scope)}</code>` : ''} en <code>${esc(r.dir)}</code>.</p>`
68
- : `<div class="tiles">
69
- ${u.conversations ? `<div><b>${fmt(billed(u))}</b><span>tokens reales (entrada + salida)</span></div>
70
- <div><b>${fmt(withCache(u))}</b><span>contando la caché · ${u.calls} llamadas</span></div>` : ''}
71
- <div><b>${fmt(r.tokens)}</b><span>~tokens en contexto</span></div>
72
- <div><b>${pct(top5, r.tokens)}</b><span>en las 5 sesiones más grandes</span></div>
73
- <div><b>${r.reads}</b><span>lecturas de ficheros</span></div>
74
- <div><b>${pct(r.rereads, r.reads)}</b><span>relecturas del mismo trozo</span></div>
75
- </div>
76
- <h2>Qué está pasando</h2>
77
- <p class="hint">Esta página no cambia nada. Cada propuesta te dice en qué fichero va y qué pegar; decides tú.</p>
78
- ${found.length ? found.map((f) => finding(f, r.tokens)).join('') : '<p>No se ha detectado contexto repetido ni innecesario.</p>'}
79
- <details class="detail-all"><summary><h2>Detalle</h2></summary>
80
- ${sessionsTable(r)}
81
- ${table('Por proyecto', r.projects, r.tokens, (x) => x.project || 'fuera de un repositorio git')}
82
- ${table('Por tipo de fichero', r.kinds, r.tokens, (x) => x.key)}
83
- ${table('Por carpeta', r.folders, r.tokens)}
84
- ${table('Ficheros', r.files, r.tokens)}
85
- ${table('Herramientas', r.tools, r.tokens, (x) => x.key)}
86
- ${table('Comandos', r.commands, r.tokens, (x) => x.key)}
87
- </details>`;
88
-
89
- return `<!doctype html><html lang="es"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
161
+ const meta = `<p class="meta">${t.report.convs(r.conversations)}${r.period ? ` · ${esc(periodLine(r))}` : ''}</p>`;
162
+ if (!r.conversations) {
163
+ return `${meta}<p>${t.report.noConversations(scope && esc(scope), esc(r.dir))}${r.olderSkipped ? ` ${esc(t.report.olderSkipped(r.olderSkipped))}.` : ''}</p>`;
164
+ }
165
+ return `${meta}<div class="tiles">
166
+ ${u.conversations ? `<div><b>${fmt(billed(u))}</b><span>${t.report.tileReal}</span></div>
167
+ <div><b>${fmt(withCache(u))}</b><span>${t.report.tileCache(u.calls)}</span></div>` : ''}
168
+ <div><b>${fmt(r.tokens)}</b><span>${t.report.tileContext}</span></div>
169
+ <div><b>${pct(top5, r.tokens)}</b><span>${t.report.tileTop5}</span></div>
170
+ <div><b>${r.reads}</b><span>${t.report.tileReads}</span></div>
171
+ <div><b>${pct(r.rereads, r.reads)}</b><span>${t.report.tileRereads}</span></div>
172
+ </div>
173
+ ${chart(r)}
174
+ ${modelsTable(r)}
175
+ <h2>${t.report.happening}</h2>
176
+ <p class="hint">${t.report.happeningHint}</p>
177
+ ${found.length ? found.map((f) => finding(f, r.tokens)).join('') : `<p>${t.report.nothing}</p>`}
178
+ <details class="detail-all"><summary><h2>${t.report.details}</h2></summary>
179
+ ${sessionsTable(r)}
180
+ ${table(t.report.byProject, r.projects, r.tokens, (x) => x.project || t.report.outsideGit)}
181
+ ${table(t.report.byKind, r.kinds, r.tokens, (x) => kindLabel(x.key))}
182
+ ${table(t.report.byFolder, r.folders, r.tokens)}
183
+ ${table(t.report.files, r.files, r.tokens)}
184
+ ${table(t.report.tools, r.tools, r.tokens, (x) => x.key)}
185
+ ${table(t.report.commands, r.commands, r.tokens, (x) => x.key)}
186
+ </details>`;
187
+ }
188
+
189
+ /**
190
+ * One report per period, switched in the browser by a row of presets. Each period is analyzed on its own,
191
+ * so every number, finding and table below the filter agrees with it.
192
+ * @param {{ key: string, label: string, r: object, found: object[] }[]} views the first one shows by default
193
+ */
194
+ function htmlViews(views, { scope }) {
195
+ const u = views[0].r.usage;
196
+ const filter = views.length > 1
197
+ ? `<div class="filter" role="group" aria-label="${esc(t.report.filterLabel)}"><span>${t.report.filterLabel}</span>
198
+ ${views.map((v, i) => `<button type="button" data-view="${v.key}" aria-pressed="${i === 0}">${esc(v.label)}</button>`).join('')}</div>`
199
+ : '';
200
+ const sections = views.map((v, i) => `<div class="view" data-view="${v.key}"${i ? ' hidden' : ''}>${body(v.r, v.found, scope)}</div>`).join('');
201
+
202
+ return `<!doctype html><html lang="${lang()}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
90
203
  <title>agy-insights</title>
91
204
  <style>
92
205
  :root { --bg: #f7f8f7; --surface: #fff; --fg: #1d2421; --muted: #5d6963; --line: #dde3df; --accent: #1f7a5a; --accent-soft: #e3f1ea;
93
- --warn: #b06a00; --code: #eef1ef; --bar: #1f7a5a; color-scheme: light; }
206
+ --warn: #b06a00; --code: #eef1ef; --bar: #1f7a5a; --series: #13805a; color-scheme: light; }
94
207
  @media (prefers-color-scheme: dark) { :root { --bg: #141917; --surface: #1b211e; --fg: #e3e9e5; --muted: #9aa8a0; --line: #2c3530;
95
- --accent: #5cc79c; --accent-soft: #1d3329; --warn: #e2a64a; --code: #222a26; --bar: #5cc79c; color-scheme: dark; } }
208
+ --accent: #5cc79c; --accent-soft: #1d3329; --warn: #e2a64a; --code: #222a26; --bar: #5cc79c; --series: #33a376; color-scheme: dark; } }
96
209
  body { margin: 0; background: var(--bg); color: var(--fg); font: 15px/1.55 system-ui, -apple-system, "Segoe UI", sans-serif; }
97
210
  main { max-width: 980px; margin: 0 auto; padding: 32px 16px 48px; }
98
211
  h1 { font-size: 26px; margin: 0 0 4px; } h2 { font-size: 20px; margin: 32px 0 6px; display: inline-block; } h3 { font-size: 16px; margin: 0 0 6px; }
99
212
  .meta, .hint, .detail, .tok, .where { color: var(--muted); } .tok { font-weight: normal; font-size: 14px; }
100
213
  code, pre { font-family: ui-monospace, "Cascadia Mono", Consolas, monospace; font-size: 13px; }
101
214
  code { background: var(--code); padding: 1px 5px; border-radius: 4px; overflow-wrap: anywhere; }
102
- .tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 12px; margin: 20px 0; }
215
+ .filter { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin: 20px 0 4px; }
216
+ .filter span { color: var(--muted); font-size: 14px; margin-right: 4px; }
217
+ .filter button { font: 14px system-ui, sans-serif; color: var(--fg); background: var(--surface); border: 1px solid var(--line); border-radius: 999px; padding: 4px 12px; cursor: pointer; }
218
+ .filter button:hover { background: var(--accent-soft); }
219
+ .filter button[aria-pressed="true"] { background: var(--accent); border-color: var(--accent); color: var(--surface); font-weight: 600; }
220
+ .filter button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
221
+ .tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 12px; margin: 16px 0 20px; }
103
222
  .tiles div { background: var(--surface); border: 1px solid var(--line); border-radius: 8px; padding: 12px 14px; }
104
223
  .tiles b { display: block; font-size: 24px; font-variant-numeric: tabular-nums; } .tiles span { color: var(--muted); font-size: 14px; }
224
+ .chart h2 { margin-top: 8px; } .models .hint { margin-top: 0; } .chart .hint { margin-top: 0; }
225
+ .plot { background: var(--surface); border: 1px solid var(--line); border-radius: 8px; padding: 8px 8px 0; }
226
+ .plot svg { display: block; width: 100%; height: auto; }
227
+ .grid { stroke: var(--line); stroke-width: 1; } .axis { fill: var(--muted); font: 12px system-ui, sans-serif; font-variant-numeric: tabular-nums; }
228
+ .col { fill: var(--series); } .hit { fill: transparent; cursor: default; } .hit:hover { fill: var(--fg); fill-opacity: .05; }
229
+ .tip { position: fixed; pointer-events: none; background: var(--surface); color: var(--fg); border: 1px solid var(--line); border-radius: 6px;
230
+ padding: 6px 10px; font-size: 13px; box-shadow: 0 2px 8px rgb(0 0 0 / .12); z-index: 10; }
231
+ .tip b { display: block; font-size: 15px; font-variant-numeric: tabular-nums; } .tip span { display: block; color: var(--muted); }
105
232
  .finding { background: var(--surface); border: 1px solid var(--line); border-left: 4px solid var(--accent); border-radius: 8px; padding: 14px 16px; margin: 12px 0; }
106
233
  .finding.warn { border-left-color: var(--warn); } .finding p { margin: 4px 0; }
107
234
  .items { list-style: none; margin: 10px 0 0; padding: 0; }
@@ -125,59 +252,97 @@ function html(r, found, { scope }) {
125
252
  .detail-all > summary { color: var(--fg); }
126
253
  footer { margin-top: 32px; font-size: 13px; color: var(--muted); }
127
254
  </style></head><body><main>
128
- <h1>Uso de contexto en Antigravity</h1>
129
- <p class="meta">${scope ? `Proyecto <code>${esc(scope)}</code>` : 'Todo el historial'} · ${r.conversations} conversaciones · generado ${esc(new Date().toLocaleString('es-ES'))}</p>
130
- ${body}
131
- <footer>Tokens reales: los que Antigravity anota en el registro de cada conversación (incluidos subagentes)${u.conversations < r.conversations ? `; ${r.conversations - u.conversations} conversaciones no tienen registro y no cuentan` : ''}.
132
- No se sabe si la cuota cuenta la caché igual que la entrada nueva: por eso se muestran las dos cifras.
133
- ~tokens = tamaño guardado de cada paso / 4: sirve para comparar ficheros y carpetas entre sí.
134
- El historial se ha leído desde una copia y no se ha modificado. Esta página contiene rutas de tus proyectos: no la compartas sin revisarla.</footer>
255
+ <h1>${t.report.title}</h1>
256
+ <p class="meta">${scope ? `${t.report.project} <code>${esc(scope)}</code> · ` : views[0].r.since ? '' : `${t.report.wholeHistory} · `}${esc(t.report.generated(new Date().toLocaleString(t.locale)))}</p>
257
+ ${filter}
258
+ ${sections}
259
+ <footer>${esc(t.report.footerReal(views[0].r.conversations - u.conversations))}
260
+ ${esc(t.report.footerCache)}
261
+ ${esc(t.report.footerEstimate)}
262
+ ${esc(t.report.footerPrivacy)}</footer>
135
263
  </main>
264
+ <div class="tip" hidden><b></b><span></span><span></span></div>
136
265
  <script>
137
266
  document.addEventListener('click', (e) => {
267
+ const v = e.target.closest('.filter button');
268
+ if (v) {
269
+ for (const b of document.querySelectorAll('.filter button')) b.setAttribute('aria-pressed', String(b === v));
270
+ for (const s of document.querySelectorAll('.view')) s.hidden = s.dataset.view !== v.dataset.view;
271
+ return;
272
+ }
138
273
  const b = e.target.closest('.copy'); if (!b) return;
139
274
  const pre = b.parentElement.querySelector('pre');
140
- const ok = () => { b.textContent = 'Copiado'; setTimeout(() => (b.textContent = 'Copiar'), 1500); };
275
+ const ok = () => { b.textContent = ${JSON.stringify(t.report.copied)}; setTimeout(() => (b.textContent = ${JSON.stringify(t.report.copy)}), 1500); };
141
276
  (navigator.clipboard ? navigator.clipboard.writeText(pre.textContent) : Promise.reject()).then(ok).catch(() => {
142
277
  const range = document.createRange(); range.selectNodeContents(pre);
143
278
  const sel = getSelection(); sel.removeAllRanges(); sel.addRange(range);
144
279
  });
145
280
  });
281
+ // Chart tooltip: values are filled with textContent, never as HTML.
282
+ const tip = document.querySelector('.tip');
283
+ document.addEventListener('pointermove', (e) => {
284
+ const h = e.target.closest && e.target.closest('.hit');
285
+ if (!h) { tip.hidden = true; return; }
286
+ const [v, when, rest] = tip.children;
287
+ v.textContent = h.dataset.v + ' tokens';
288
+ when.textContent = h.dataset.when;
289
+ rest.textContent = h.dataset.n + ' · ' + h.dataset.c + ' ' + ${JSON.stringify(t.report.withCacheShort)};
290
+ tip.hidden = false;
291
+ const x = Math.min(e.clientX + 14, innerWidth - tip.offsetWidth - 8);
292
+ tip.style.left = x + 'px';
293
+ tip.style.top = (e.clientY - tip.offsetHeight - 10) + 'px';
294
+ });
146
295
  </script></body></html>`;
147
296
  }
148
297
 
298
+ function html(r, found, opts) {
299
+ return htmlViews([{ key: 'all', label: '', r, found }], opts);
300
+ }
301
+
302
+
149
303
  // --- Text ---
150
304
 
151
305
  function text(r, found, { scope, paths }) {
152
306
  const top5 = r.sessions.slice(0, 5).reduce((s, x) => s + x.tokens, 0);
153
- const out = [`\nHistorial: ${r.dir} (${r.backend})${scope ? ` · proyecto ${scope}` : ''}`,
154
- `${r.conversations} conversaciones, ${r.steps} pasos, ~${fmt(r.tokens)} tokens en contexto (estimado)`];
307
+ const out = [`\n${t.report.history}: ${r.dir} (${r.backend})${scope ? ` · ${t.report.projectLower} ${scope}` : ''}`,
308
+ t.report.summary(r.conversations, r.steps, fmt(r.tokens))];
309
+ if (r.period) out.push(periodLine(r));
310
+ if (r.olderSkipped) out.push(`(${t.report.olderSkipped(r.olderSkipped)})`);
155
311
  if (!r.conversations) return out.join('\n') + '\n';
156
312
  const u = r.usage;
157
313
  if (u.conversations) {
158
- out.push(`Consumo real: ${fmt(billed(u))} tokens (entrada ${fmt(u.input)} + salida ${fmt(u.output)}), ${fmt(withCache(u))} contando la caché, en ${u.calls} llamadas al modelo`
159
- + (u.conversations < r.conversations ? ` (${r.conversations - u.conversations} conversaciones sin registro)` : ''));
314
+ out.push(t.report.realUsage(fmt(billed(u)), fmt(u.input), fmt(u.output), fmt(withCache(u)), u.calls)
315
+ + (u.conversations < r.conversations ? t.report.noLog(r.conversations - u.conversations) : ''));
160
316
  const top = r.sessions.filter((s) => s.usage).slice(0, 5);
161
- out.push(` Las ${top.length} sesiones que más gastan:`);
317
+ out.push(t.report.topSessions(top.length));
162
318
  for (const s of top) {
163
- out.push(` ${fmt(billed(s.usage)).padStart(10)} ${pct(billed(s.usage), billed(u)).padStart(4)} conversación ${s.id.slice(0, 8)} ${s.usage.calls} llamadas${s.usage.errors ? `, ${s.usage.errors} errores` : ''}, ${fmt(withCache(s.usage))} con caché`);
319
+ out.push(` ${fmt(billed(s.usage)).padStart(10)} ${pct(billed(s.usage), billed(u)).padStart(4)} ${t.report.sessionLine(s.id.slice(0, 8), s.usage.calls, s.usage.errors, fmt(withCache(s.usage)), sessionModel(s))}`);
320
+ }
321
+ }
322
+ out.push(t.report.top5(pct(top5, r.tokens), r.reads, r.rereads, pct(r.rereads, r.reads)));
323
+ const models = r.models.filter((m) => m.model);
324
+ if (models.length) {
325
+ const total = r.models.reduce((s, m) => s + m.input + m.output, 0);
326
+ out.push('', t.report.modelsText);
327
+ for (const m of models) {
328
+ const tok = m.input + m.output;
329
+ out.push(` ${t.report.modelLine(m.model, effortLabel(m.effort), m.calls, fmt(tok), pct(tok, total), m.known ? fmt(tok / m.known) : '–')}`);
164
330
  }
165
331
  }
166
- out.push(`Las 5 sesiones más grandes suman el ${pct(top5, r.tokens)}. Lecturas: ${r.reads}, relecturas ${r.rereads} (${pct(r.rereads, r.reads)})`);
167
332
  for (const f of found) {
168
333
  out.push('', `${f.level === 'warn' ? '⚠' : '•'} ${f.title}${f.tokens ? ` (~${fmt(f.tokens)} tokens, ${pct(f.tokens, r.tokens)})` : ''}`,
169
334
  ` ${f.why}`, ` → ${f.suggestion}`);
170
335
  // Only file and folder labels are paths; commands and session ids are always shown.
171
336
  const hasPaths = ['rereads', 'non-source', 'cross-session'].includes(f.id);
172
337
  for (const it of f.items) {
173
- out.push(` ${(it.tokens ? `~${fmt(it.tokens)}` : '').padStart(10)} ${paths || !hasPaths ? it.label : '(ruta oculta, usa --paths)'} ${it.detail || ''}`);
338
+ out.push(` ${(it.tokens ? `~${fmt(it.tokens)}` : '').padStart(10)} ${paths || !hasPaths ? it.label : t.report.pathHidden} ${it.detail || ''}`);
174
339
  if (paths) for (const x of it.fixes.filter((x) => !x.applied)) out.push(`${' '.repeat(16)}${x.title}: ${x.text.split('\n').join(' ')} → ${x.file}`);
175
340
  }
176
341
  }
177
- out.push('', 'Por tipo de fichero:');
178
- for (const k of r.kinds.slice(0, 10)) out.push(` ${`~${fmt(k.tokens)}`.padStart(10)} ${String(k.calls).padStart(5)}× ${k.key}`);
179
- out.push('', 'No se ha modificado nada. Consumo real = registro de agy; ~tokens = tamaño del paso / 4 (estimado).', '');
342
+ out.push('', t.report.byKindText);
343
+ for (const k of r.kinds.slice(0, 10)) out.push(` ${`~${fmt(k.tokens)}`.padStart(10)} ${String(k.calls).padStart(5)}× ${kindLabel(k.key)}`);
344
+ out.push('', t.report.textFooter, '');
180
345
  return out.join('\n');
181
346
  }
182
347
 
183
- module.exports = { html, text };
348
+ module.exports = { html, htmlViews, text };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "agy-insights",
3
- "version": "0.3.0",
4
- "description": "Muestra qué contexto se envía a Antigravity de forma repetida o innecesaria, a partir del historial local de Antigravity CLI.",
3
+ "version": "0.7.0",
4
+ "description": "Measures Antigravity CLI token usage and shows which context is sent repeatedly or needlessly, in English or Spanish. Run: npx agy-insights@latest",
5
5
  "license": "MIT",
6
6
  "keywords": [
7
7
  "antigravity",
@@ -9,7 +9,10 @@
9
9
  "gemini",
10
10
  "tokens",
11
11
  "context",
12
- "cli"
12
+ "cli",
13
+ "token-usage",
14
+ "optimization",
15
+ "i18n"
13
16
  ],
14
17
  "bin": {
15
18
  "agy-insights": "agy-insights.js"
@@ -19,7 +22,8 @@
19
22
  "lib/",
20
23
  "vendor/",
21
24
  "agy-insights.example.json",
22
- "docs/"
25
+ "docs/",
26
+ "README.es.md"
23
27
  ],
24
28
  "engines": {
25
29
  "node": ">=20"
@@ -1,88 +0,0 @@
1
- # Cómo funciona
2
-
3
- Documento para quien mantenga o amplíe agy-insights.
4
-
5
- ## Principios
6
-
7
- Cualquier cambio tiene que respetarlos:
8
-
9
- 1. **Solo lectura.** No se escribe en `~/.gemini` ni en los proyectos del dev. El historial se copia a una carpeta temporal antes de abrirlo, porque `agy` mantiene esas bases de datos abiertas. Lo único que se escribe es el informe.
10
- 2. **El dev decide.** La herramienta propone texto para pegar; nunca lo aplica.
11
- 3. **Solo mecanismos oficiales de Antigravity** para excluir: permisos (`read_file` en `permissions.deny`), `.antigravityignore` y reglas en `AGENTS.md`. Nada de hooks, plugins, `.gitignore` ni `.ignore`.
12
- 4. **Fallar de forma visible.** Si el formato del historial cambia, el informe debe avisar en vez de mostrar datos falsos.
13
- 5. **Sin dependencias que instalar.** Solo Node 20 o superior; SQLite va en `vendor/`.
14
-
15
- ## Estructura
16
-
17
- ```
18
- agy-insights.js comando: opciones, configuración, escribe el informe y abre el navegador
19
- lib/history.js lee el historial y agrega lecturas, comandos, proyectos y sesiones
20
- lib/findings.js convierte el análisis en hallazgos (los 5 detectores)
21
- lib/fixes.js construye el texto de cada exclusión; no escribe nada
22
- lib/report.js informe HTML (estático, solo botones de copiar) y resumen de texto
23
- vendor/sql.js/ SQLite en WebAssembly (versión en VERSION, licencia MIT en LICENSE)
24
- test/fake-history.js genera historiales falsos con el mismo formato que los reales
25
- test/*.test.js tests (node --test)
26
- ```
27
-
28
- ## El historial de Antigravity CLI
29
-
30
- No está documentado; esto es lo que se ha observado en `agy` 1.4.x.
31
-
32
- - Una base de datos SQLite por conversación: `~/.gemini/antigravity-cli/conversations/<uuid>.db`, en modo WAL.
33
- - Tabla `steps`: una fila por paso. `step_payload` es un mensaje protobuf. Las llamadas a herramientas guardan el nombre (`view_file`, `run_command`, `grep_search`…) seguido de sus argumentos en JSON, por ejemplo `{"AbsolutePath":"C:\\dev\\...\\X.cs","StartLine":301,"EndLine":380}`, precedido por un id `call_NNN`.
34
- - Cada llamada ocupa dos pasos con el mismo id: el de la respuesta del modelo que la pide (`step_type` 15) y el del resultado (`step_type` 132), que repite nombre y argumentos y añade el contenido del fichero o la salida del comando. `lib/history.js` cuenta la llamada una vez por id y suma el tamaño de ambos pasos, que aproxima lo que entró en el contexto.
35
- - Tabla `gen_metadata`: configuración de cada llamada al modelo, incluido el nombre del modelo.
36
- - Junto al historial, `../brain/<id>/.system_generated/logs/transcript.jsonl`: una línea JSON por paso. Las de tipo `PLANNER_RESPONSE` son llamadas al modelo y traen `input_tokens`, `cache_read_tokens` y `output_tokens`; los pasos fallidos tienen `status: "ERROR"`. Los subagentes tienen su propia conversación y su propio transcript. `realUsage()` en `lib/history.js` suma esas cifras (solo lee el fichero). El total que imprime `agy -p --output-format json` no incluye los subagentes; este sí.
37
-
38
- `lib/history.js` no depende de un esquema protobuf. Recorre el mensaje, recoge todas las cadenas, y considera llamada a herramienta cualquier JSON precedido (a menos de 3 cadenas) por un nombre en `snake_case`. Así aguanta cambios menores de formato. Si hay más de 50 pasos y ninguna llamada reconocida, marca `formatUnrecognized` y el informe lo avisa.
39
-
40
- ### SQLite
41
-
42
- - Con Node 22.13 o superior se usa `node:sqlite`, que lee también el fichero `-wal`.
43
- - Con versiones anteriores se usa `sql.js` desde `vendor/`. No lee el `-wal`, así que pueden faltar los últimos pasos de una conversación abierta.
44
-
45
- Para actualizar `sql.js`: copia `dist/sql-wasm.js`, `dist/sql-wasm.wasm` y `LICENSE` de la nueva versión del paquete npm y actualiza `VERSION`.
46
-
47
- ### Proyectos
48
-
49
- El proyecto de cada fichero se detecta subiendo por su ruta hasta encontrar una carpeta con `.git`, si la ruta existe en la máquina. Con `--project` se usa esa carpeta directamente. Las rutas de Windows (`C:\...`) se normalizan a `/` internamente y se devuelven con `\` en las exclusiones, porque `agy` compara las rutas de los permisos literalmente.
50
-
51
- ## Añadir un tipo de fichero
52
-
53
- En `lib/history.js`, función `kind()`. Si es un tipo que casi nunca hace falta leer, añádelo también a `NON_SOURCE`, y sus patrones de `.antigravityignore` a `KIND_PATTERNS` en `lib/findings.js`. Cada dev puede añadir tipos propios sin tocar el código con `customKinds` en su configuración.
54
-
55
- ## Añadir un detector
56
-
57
- En `lib/findings.js`. Un hallazgo es `{ id, level: 'info' | 'warn', title, why, suggestion, tokens, items }`, y cada `item` es `{ label, tokens, detail, fixes }`. Las `fixes` se crean con `lib/fixes.js`. No propongas excluir código fuente: el agente lo necesita.
58
-
59
- Añade un caso en `test/fake-history.js` y un test.
60
-
61
- ## Tests
62
-
63
- ```sh
64
- npm test
65
- ```
66
-
67
- Prueban el análisis con un historial falso, los cinco detectores, el formato de las reglas, que nunca se propone excluir código fuente, que el historial no cambia, la detección de proyectos por `.git`, el aviso de formato desconocido, el escapado del HTML y el comando completo. Se ejecutan en CI con Node 20 (sql.js) y Node 22 (node:sqlite).
68
-
69
- Para generar un historial de ejemplo y probar el informe a mano:
70
-
71
- ```sh
72
- node test/fake-history.js test/tmp/history
73
- node agy-insights.js --dir test/tmp/history
74
- ```
75
-
76
- ## Paquete npm
77
-
78
- `package.json` define el comando (`bin`) y qué ficheros entran en el paquete (`files`: el comando, `lib/`, `vendor/`, `docs/` y el ejemplo de configuración; los tests no). Se publica en el registro público de npm como `agy-insights`. El paquete solo contiene la herramienta; los historiales que analiza se quedan en la máquina de cada usuario.
79
-
80
- - **Probar el paquete en local**: `npm pack` y después `npx --yes --package=./agy-insights-<versión>.tgz agy-insights --help`.
81
- - **Desde el repositorio**, sin publicar: `npx --yes github:migohe14/agy-insights`. Al no tener dependencias ni paso de build, npm lo instala directamente desde git.
82
- - **Publicar una versión**: `npm version <patch|minor|major>` (actualiza `package.json` y crea la etiqueta `v…`) y `git push --follow-tags`. El workflow `.github/workflows/publish.yml` pasa los tests y ejecuta `npm publish --provenance`. Necesita el secreto `NPM_TOKEN` en el repositorio de GitHub (un token de npm de tipo *Automation* o *Granular* con permiso de publicación).
83
-
84
- ## Pendiente
85
-
86
- - Confirmar con `agy` que una regla `deny` de `read_file` impide la lectura.
87
- - Probar con historiales reales de varios devs (Windows y macOS).
88
- - Leer también el historial de la app y del IDE de Antigravity, si guardan el mismo formato.