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/README.es.md +172 -0
- package/README.md +95 -77
- package/agy-insights.example.json +2 -2
- package/agy-insights.js +51 -28
- package/docs/how-it-works.md +102 -0
- package/lib/findings.js +45 -61
- package/lib/fixes.js +10 -9
- package/lib/history.js +84 -18
- package/lib/i18n.js +357 -0
- package/lib/map.js +8 -7
- package/lib/report.js +224 -59
- package/package.json +8 -4
- package/docs/como-funciona.md +0 -88
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
|
|
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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
|
|
5
|
-
const fmt =
|
|
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 ?
|
|
18
|
-
<p class="where"
|
|
19
|
-
<div class="code"><button class="copy" type="button"
|
|
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
|
|
32
|
+
? `<details><summary>${t.report.howToExclude}</summary>${it.fixes.map(fixBlock).join('')}</details>`
|
|
27
33
|
: it.example
|
|
28
|
-
? `<details><summary
|
|
29
|
-
<p class="hint"
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
67
|
-
|
|
68
|
-
:
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
</div>
|
|
76
|
-
<
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
.
|
|
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
|
|
129
|
-
<p class="meta">${scope ?
|
|
130
|
-
${
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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 =
|
|
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 = [`\
|
|
154
|
-
|
|
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(
|
|
159
|
-
+ (u.conversations < r.conversations ?
|
|
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(
|
|
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)}
|
|
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 :
|
|
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('',
|
|
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('',
|
|
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.
|
|
4
|
-
"description": "
|
|
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"
|
package/docs/como-funciona.md
DELETED
|
@@ -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.
|