agy-insights 0.7.3 → 0.8.1

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 CHANGED
@@ -36,7 +36,7 @@ npx agy-insights
36
36
  npx agy-insights@latest --text
37
37
  ```
38
38
 
39
- `@latest` hace que npx compruebe cada vez si hay una versión nueva. Sin él, una copia instalada globalmente (`npm install -g`) o en el proyecto actual tiene prioridad y no se actualiza nunca. Para fijar una versión concreta: `npx agy-insights@0.7.3`.
39
+ `@latest` hace que npx compruebe cada vez si hay una versión nueva. Sin él, una copia instalada globalmente (`npm install -g`) o en el proyecto actual tiene prioridad y no se actualiza nunca. Para fijar una versión concreta: `npx agy-insights@0.8.1`.
40
40
 
41
41
  **Instalándolo globalmente:**
42
42
 
@@ -61,7 +61,7 @@ Y ejecútalo con `node agy-insights/agy-insights.js` en lugar de `npx agy-insigh
61
61
  npx agy-insights
62
62
  ```
63
63
 
64
- Analiza todo tu historial (todas las conversaciones que guarde agy, sin límite de fecha), guarda un informe HTML en la carpeta temporal y lo abre en el navegador. En el informe puedes cambiar entre todo el historial y los últimos 90, 30 o 7 días: los totales, la gráfica, los hallazgos y las tablas siguen el periodo elegido.
64
+ Analiza todo tu historial (todas las conversaciones que guarde agy, sin límite de fecha), guarda un informe HTML en la carpeta temporal y lo abre en el navegador. En el informe puedes cambiar entre todo el historial y los últimos 90 o 30 días, o esta semana (desde el lunes): los totales, la gráfica, los hallazgos y las tablas siguen el periodo elegido.
65
65
 
66
66
  | Para… | Ejecuta |
67
67
  |---|---|
@@ -82,7 +82,7 @@ El informe da dos cifras:
82
82
 
83
83
  ## Leer el informe
84
84
 
85
- Arriba están el selector de periodo y los totales. Después, una gráfica de **consumo de tokens por día** (por semana si el historial pasa de ~4 meses) para ver la evolución; pasa el ratón por una columna para ver sus cifras, o abre «Ver como tabla». Después, **Modelos y esfuerzo**: llamadas y tokens reales por modelo y nivel de esfuerzo (por ejemplo Gemini 3.8 Flash · Alto), con los tokens por llamada, para ver cuánto cuesta un esfuerzo mayor. Debajo, **Qué está pasando** reúne los hallazgos; al final, el detalle por proyecto, tipo de fichero, carpeta, fichero, herramienta y comando. El proyecto de cada fichero se detecta por su carpeta `.git`.
85
+ Arriba están el selector de periodo y los totales. Después, una gráfica de **consumo de tokens por día** (por semana si el historial pasa de ~4 meses) para ver la evolución. Cámbiala a **Por llamada** para ver los tokens medios de cada llamada al modelo: el total sube y baja según cuánto hayas trabajado, mientras que por llamada solo baja si cada llamada lleva menos contexto, así que es la que muestra si un cambio ha servido. Pasa el ratón por una columna para ver sus cifras, o abre «Ver como tabla». Después, **Modelos y esfuerzo**: llamadas y tokens reales por modelo y nivel de esfuerzo (por ejemplo Gemini 3.8 Flash · Alto), con los tokens por llamada, para ver cuánto cuesta un esfuerzo mayor. Debajo, **Qué está pasando** reúne los hallazgos; al final, el detalle por proyecto, tipo de fichero, carpeta, fichero, herramienta y comando. El proyecto de cada fichero se detecta por su carpeta `.git`.
86
86
 
87
87
  | Hallazgo | Qué significa | Qué hacer |
88
88
  |---|---|---|
package/README.md CHANGED
@@ -36,7 +36,7 @@ npx agy-insights
36
36
  npx agy-insights@latest --text
37
37
  ```
38
38
 
39
- `@latest` makes npx check for the newest version every time. Without it, a copy installed globally (`npm install -g`) or in the current project takes precedence and is never updated. To pin a version instead: `npx agy-insights@0.7.3`.
39
+ `@latest` makes npx check for the newest version every time. Without it, a copy installed globally (`npm install -g`) or in the current project takes precedence and is never updated. To pin a version instead: `npx agy-insights@0.8.1`.
40
40
 
41
41
  **Globally:**
42
42
 
@@ -61,7 +61,7 @@ Then run it with `node agy-insights/agy-insights.js` instead of `npx agy-insight
61
61
  npx agy-insights
62
62
  ```
63
63
 
64
- Analyzes your whole history (every conversation agy has kept, with no date limit), saves an HTML report in the temp folder and opens it in the browser. In the report you can switch between the whole history and the last 90, 30 or 7 days: totals, chart, findings and tables all follow the selected period.
64
+ Analyzes your whole history (every conversation agy has kept, with no date limit), saves an HTML report in the temp folder and opens it in the browser. In the report you can switch between the whole history and the last 90 or 30 days, or this week (since Monday): totals, chart, findings and tables all follow the selected period.
65
65
 
66
66
  | To… | Run |
67
67
  |---|---|
@@ -82,7 +82,7 @@ The report gives two figures:
82
82
 
83
83
  ## Reading the report
84
84
 
85
- At the top, the period selector and the totals. Then a chart of **token usage per day** (per week when the history spans more than ~4 months), to see how usage evolves; hover a column for its figures, or open "View as table". Next, **Models and effort**: calls and real tokens per model and effort level (for example Gemini 3.8 Flash · High), including tokens per call, so you can see what a higher effort costs. Below, **What's happening** lists the findings; at the end, the details by project, file type, folder, file, tool and command. The project of each file is detected through its `.git` folder.
85
+ At the top, the period selector and the totals. Then a chart of **token usage per day** (per week when the history spans more than ~4 months), to see how usage evolves. Switch it to **Per call** to see mean tokens per model call: the total follows how much you worked, while per call drops only when each call carries less context, so it is the one that shows whether a change paid off. Hover a column for its figures, or open "View as table". Next, **Models and effort**: calls and real tokens per model and effort level (for example Gemini 3.8 Flash · High), including tokens per call, so you can see what a higher effort costs. Below, **What's happening** lists the findings; at the end, the details by project, file type, folder, file, tool and command. The project of each file is detected through its `.git` folder.
86
86
 
87
87
  | Finding | What it means | What to do |
88
88
  |---|---|---|
package/agy-insights.js CHANGED
@@ -86,19 +86,28 @@ function sinceTime({ since, days }) {
86
86
  // Tab-separated table of where each conversation's call models came from, for bug reports.
87
87
  function debugModels(r) {
88
88
  const d = t.debugModels;
89
- const sum = { rows: 0, rowsWithModel: 0, calls: 0, fromTranscript: 0, unknown: 0 };
89
+ const sum = { rows: 0, rowsWithModel: 0, calls: 0, fromTranscript: 0, unknown: 0, unmetered: 0 };
90
90
  const lines = [d.header.join('\t')];
91
91
  for (const s of r.sessions) {
92
92
  const m = s.modelSources;
93
93
  for (const k of Object.keys(sum)) sum[k] += m[k];
94
- lines.push([s.id.slice(0, 8), m.calls, m.rows, m.rowsWithModel, m.fromTranscript, m.unknown, s.model ? `${s.model.model} (${s.model.effort})` : '', m.unknown ? m.candidates.join(' | ') : ''].join('\t'));
94
+ lines.push([s.id.slice(0, 8), m.calls, m.rows, m.rowsWithModel, m.fromTranscript, m.unknown, s.model ? `${s.model.model} (${s.model.effort})` : '', m.unknown ? m.candidates.join(' | ') : '',
95
+ m.unmetered || '', m.unmetered ? m.unmeteredKeys.join(' ') : ''].join('\t'));
95
96
  }
96
- lines.push([d.total, sum.calls, sum.rows, sum.rowsWithModel, sum.fromTranscript, sum.unknown, '', ''].join('\t'), '', d.note, '');
97
+ lines.push([d.total, sum.calls, sum.rows, sum.rowsWithModel, sum.fromTranscript, sum.unknown, '', '', sum.unmetered || '', ''].join('\t'), '', d.note, '');
97
98
  return lines.join('\n');
98
99
  }
99
100
 
100
101
  // Periods the HTML report can switch to, besides the whole history.
101
- const PRESET_DAYS = [90, 30, 7];
102
+ const PRESET_DAYS = [90, 30];
103
+
104
+ // This Monday at local midnight.
105
+ function weekStart() {
106
+ const d = new Date();
107
+ d.setHours(0, 0, 0, 0);
108
+ d.setDate(d.getDate() - ((d.getDay() + 6) % 7));
109
+ return d.getTime();
110
+ }
102
111
 
103
112
  async function main() {
104
113
  const argv = process.argv.slice(2);
@@ -144,6 +153,8 @@ async function main() {
144
153
  const x = await run(Date.now() - days * 24 * 60 * 60 * 1000);
145
154
  views.push({ key: `d${days}`, label: t.report.rangeDays(days), r: x, found: find(x) });
146
155
  }
156
+ const week = await run(weekStart());
157
+ views.push({ key: 'week', label: t.report.rangeWeek, r: week, found: find(week) });
147
158
  }
148
159
  fs.writeFileSync(out, report.htmlViews(views, { scope: opts.project }));
149
160
  console.log(t.done(r.conversations, found.length, out));
@@ -39,7 +39,7 @@ It is undocumented; this is what has been observed in `agy` 1.4.x.
39
39
 
40
40
  Each `transcript.jsonl` line also has `created_at` (ISO time). A conversation is dated by the first and last of those; without a transcript, by the `.db` file's last write. `--since` / `--days` keep a conversation when its last activity falls inside the period, and skip the rest before opening them. `../conversation_summaries.db` has one row per conversation: its short `title` (or `preview` in older ones) names each session in the report instead of its id; `last_modified_time` is not used. If the file is missing, sessions are shown by id. Each model call's `created_at` also puts its tokens on a local day (`r.daily`), which feeds the usage chart.
41
41
 
42
- The HTML report holds one view per period (whole history, last 90, 30 and 7 days): `agy-insights.js` runs `analyze()` and `findings()` once per period and `report.htmlViews()` renders them all, so switching in the browser needs no analysis code in the page and every number agrees with its period. The chart is inline SVG; its color (`--series`) is checked for contrast, chroma and lightness against both surfaces.
42
+ The HTML report holds one view per period (whole history, last 90 and 30 days, this week since Monday): `agy-insights.js` runs `analyze()` and `findings()` once per period and `report.htmlViews()` renders them all, so switching in the browser needs no analysis code in the page and every number agrees with its period. The chart is inline SVG; its color (`--series`) is checked for contrast, chroma and lightness against both surfaces.
43
43
 
44
44
  `lib/history.js` does not depend on a protobuf schema. It walks the message, collects every string, and treats as a tool call any JSON preceded (within 3 strings) by a `snake_case` name. That way it survives minor format changes. If there are more than 50 steps and no recognized call, it sets `formatUnrecognized` and the report warns about it.
45
45
 
package/lib/findings.js CHANGED
@@ -10,7 +10,7 @@ const DEFAULTS = { crossSessionMin: 3, heavyOutputTokens: 2000, longSessionToken
10
10
  // Suggested .antigravityignore patterns for the built-in non-source types.
11
11
  const KIND_PATTERNS = {
12
12
  'node_modules': ['node_modules/'],
13
- 'build': ['bin/', 'obj/', 'dist/', 'build/', 'out/', 'target/', 'coverage/'],
13
+ 'build': ['bin/', 'obj/', 'dist/', 'build/', 'out/', 'target/', 'coverage/', 'allure-results/', 'allure-report/'],
14
14
  'lockfiles': ['*.lock', 'package-lock.json', 'pnpm-lock.yaml', 'packages.lock.json'],
15
15
  'logs': ['*.log', '*.map', '*.min.js', '*.min.css'],
16
16
  'generated': ['*.designer.cs', '*.g.cs', 'Migrations/'],
@@ -126,8 +126,9 @@ function findings(r, opts = {}) {
126
126
  const size = (s) => (s.usage ? s.usage.input + s.usage.output : s.tokens);
127
127
  const long = r.sessions.filter((s) => size(s) >= o.longSessionTokens);
128
128
  if (long.length) {
129
+ // `exact`: every one has real usage, so the header shows the same figures as its items, as a share of real usage.
129
130
  out.push({
130
- id: 'long-sessions', level: 'info', title: f.longTitle, tokens: sum(long, (s) => s.tokens),
131
+ id: 'long-sessions', level: 'info', title: f.longTitle, tokens: sum(long, size), exact: long.every((s) => s.usage),
131
132
  why: f.longWhy(long.length, num(o.longSessionTokens)), suggestion: f.longSuggestion,
132
133
  items: long.slice(0, 5).map((s) => ({
133
134
  // Real tokens when agy logged them (`exact`: shown without "~"), else the size estimate.
package/lib/history.js CHANGED
@@ -154,7 +154,7 @@ function kind(p, { inRepo = false } = {}) {
154
154
  // are never reported as generated or proposed for exclusion, whatever their names look like.
155
155
  if (/(^|\/)\.gemini\//i.test(p)) return 'agent';
156
156
  if (/(^|\/)node_modules\//.test(p)) return 'node_modules';
157
- if (!inRepo && /(^|\/)(dist|build|out|target|coverage|\.next|bin|obj)\//i.test(p)) return 'build';
157
+ if (!inRepo && /(^|\/)(dist|build|out|target|coverage|allure-results|allure-report|\.next|bin|obj)\//i.test(p)) return 'build';
158
158
  if (/(package-lock\.json|yarn\.lock|pnpm-lock\.yaml|packages\.lock\.json|\.lock)$/.test(p)) return 'lockfiles';
159
159
  if (/\.(log|map)$|\.min\.(js|css)$/.test(p)) return 'logs';
160
160
  if (/(^|\/)(tests?|__tests__|specs?)\//i.test(p) || /\.(test|spec|tests?)\.\w+$/i.test(p) || /Tests?\//.test(p)) return 'tests';
@@ -221,7 +221,10 @@ function realUsage(dir, id) {
221
221
  try { text = fs.readFileSync(file, 'utf8'); } catch { return null; }
222
222
  // `days`: the same figures per local day of each model call (calls without a time go under null).
223
223
  // `perCall`: each call's figures, in order, and the model selected in the transcript when it was made.
224
- const u = { calls: 0, input: 0, cache: 0, output: 0, errors: 0, start: null, end: null, days: new Map(), perCall: [] };
224
+ // `calls` counts only the calls that carry token counts; some logs (seen with the VS Code plugin) list
225
+ // calls without them, and counting those as 0 tokens would drag every per-call figure down.
226
+ // `unmetered`: those other calls, and `unmeteredKeys` the field names they do have, for --debug-models.
227
+ const u = { calls: 0, input: 0, cache: 0, output: 0, errors: 0, start: null, end: null, days: new Map(), perCall: [], unmetered: 0, unmeteredKeys: new Set() };
225
228
  let selected = null;
226
229
  u.unrecognized = new Set();
227
230
  for (const line of text.split('\n')) {
@@ -236,11 +239,17 @@ function realUsage(dir, id) {
236
239
  for (const m of String(e.content).matchAll(MODEL_SETTING_RE)) if (!modelOf([m[1].trim()]).model) u.unrecognized.add(m[1].trim().slice(0, 60));
237
240
  }
238
241
  if (e.type !== 'PLANNER_RESPONSE') continue;
242
+ if (!['input_tokens', 'cache_read_tokens', 'output_tokens'].some((k) => k in e)) {
243
+ u.unmetered++;
244
+ for (const k of Object.keys(e)) if (k.length < 40) u.unmeteredKeys.add(k);
245
+ u.perCall.push({ metered: false, model: selected });
246
+ continue;
247
+ }
239
248
  u.calls++;
240
249
  u.input += Number(e.input_tokens) || 0;
241
250
  u.cache += Number(e.cache_read_tokens) || 0;
242
251
  u.output += Number(e.output_tokens) || 0;
243
- u.perCall.push({ input: Number(e.input_tokens) || 0, cache: Number(e.cache_read_tokens) || 0, output: Number(e.output_tokens) || 0, model: selected });
252
+ u.perCall.push({ metered: true, input: Number(e.input_tokens) || 0, cache: Number(e.cache_read_tokens) || 0, output: Number(e.output_tokens) || 0, model: selected });
244
253
  const k = at ? dayKey(at) : null;
245
254
  const d = u.days.get(k) || { calls: 0, input: 0, cache: 0, output: 0 };
246
255
  d.calls++;
@@ -317,9 +326,11 @@ async function analyze({ dir = DEFAULT_DIR, workspaces = [], customKinds = {}, s
317
326
  for (const f of dbs) {
318
327
  // When it happened: the transcript's step times, else the database's last write.
319
328
  const id = f.replace(/\.db$/, '');
320
- const usage = realUsage(dir, id);
321
- const end = usage?.end ?? fs.statSync(path.join(dir, f)).mtimeMs;
322
- const start = usage?.start ?? end;
329
+ const log = realUsage(dir, id);
330
+ const end = log?.end ?? fs.statSync(path.join(dir, f)).mtimeMs;
331
+ const start = log?.start ?? end;
332
+ // Real usage only when the log has token counts; otherwise the session is shown with the estimate.
333
+ const usage = log?.calls ? log : null;
323
334
  if (since && end < since) { r.olderSkipped++; continue; }
324
335
  let db;
325
336
  try { db = await openDb(path.join(dir, f), tmp); } catch { r.unreadable++; continue; }
@@ -395,7 +406,7 @@ async function analyze({ dir = DEFAULT_DIR, workspaces = [], customKinds = {}, s
395
406
  // calls, so the model selected in the transcript is used instead, or the conversation's only model.
396
407
  const metaStrs = db.all('select data from gen_metadata order by idx').map(([blob]) => collect(blob));
397
408
  const metaRows = metaStrs.map(modelOf);
398
- const calls = usage ? usage.perCall : metaRows.map(() => null);
409
+ const calls = log ? log.perCall : metaRows.map(() => null);
399
410
  const aligned = metaRows.length === calls.length;
400
411
  const rowModels = new Set(metaRows.filter((m) => m.model).map(modelKey));
401
412
  const onlyModel = rowModels.size === 1 ? metaRows.find((m) => m.model) : null;
@@ -412,7 +423,7 @@ async function analyze({ dir = DEFAULT_DIR, workspaces = [], customKinds = {}, s
412
423
  e.calls++;
413
424
  if (!seenModels.has(k)) { seenModels.add(k); e.convs++; }
414
425
  const c = calls[i];
415
- if (c) { e.known++; e.input += c.input; e.cache += c.cache; e.output += c.output; }
426
+ if (c?.metered) { e.known++; e.input += c.input; e.cache += c.cache; e.output += c.output; }
416
427
  models.set(k, e);
417
428
  if (m.model) counts.set(k, (counts.get(k) || 0) + 1);
418
429
  });
@@ -426,7 +437,8 @@ async function analyze({ dir = DEFAULT_DIR, workspaces = [], customKinds = {}, s
426
437
  // the short strings that look like a model id (to spot a new format). Never prompts, paths or code.
427
438
  modelSources: { rows: metaRows.length, rowsWithModel: metaRows.filter((m) => m.model).length, calls: calls.length,
428
439
  fromTranscript: calls.filter((c) => c?.model).length, unknown: callModels.filter((m) => !m.model).length,
429
- candidates: [...new Set([...(usage?.unrecognized || []),
440
+ unmetered: log?.unmetered || 0, unmeteredKeys: [...(log?.unmeteredKeys || [])].sort(),
441
+ candidates: [...new Set([...(log?.unrecognized || []),
430
442
  ...metaStrs.filter((_, i) => !metaRows[i].model).flat().filter(looksLikeModel)])].slice(0, 10) },
431
443
  });
432
444
  r.tokens += convBytes / BYTES_PER_TOKEN;
package/lib/i18n.js CHANGED
@@ -35,7 +35,7 @@ Options:
35
35
  mapDone: (tokens, file) => `\n~${tokens} tokens on every request. Paste it into ${file} if you like it; agy-insights does not write it.`,
36
36
  reportName: 'report',
37
37
  debugModels: {
38
- header: ['conversation', 'calls', 'gen_metadata rows', 'rows with model', 'calls with model in transcript', 'unknown', 'main model', 'unrecognized model ids'],
38
+ header: ['conversation', 'calls', 'gen_metadata rows', 'rows with model', 'calls with model in transcript', 'unknown', 'main model', 'unrecognized model ids', 'calls without tokens', 'fields of those calls'],
39
39
  total: 'total',
40
40
  note: 'Only counts and model names or ids: no paths, prompts or code. Paste it into an issue if models show up as unknown.',
41
41
  },
@@ -142,12 +142,19 @@ Options:
142
142
  filterLabel: 'Period',
143
143
  rangeAll: 'All history',
144
144
  rangeDays: (n) => `Last ${n} days`,
145
+ rangeWeek: 'This week',
145
146
  chartTitleDay: 'Token usage per day',
146
147
  chartTitleWeek: 'Token usage per week',
147
148
  chartHint: 'Real tokens (input + output) of the model calls. Hover a column for details.',
148
149
  chartTable: 'View as table',
149
- chartCols: ['Day', 'Tokens', 'With cache', 'Calls'],
150
- chartColsWeek: ['Week of', 'Tokens', 'With cache', 'Calls'],
150
+ chartModeLabel: 'Show',
151
+ chartModeTotal: 'Total',
152
+ chartModeCall: 'Per call',
153
+ chartHintCall: 'Mean tokens (input + output) per model call. Unlike the total, it doesn\'t depend on how much you used the agent: it drops when each call carries less context (shorter sessions, filtered command output), so it shows whether a change worked.',
154
+ perCallTip: (n) => `${n} tokens per call`,
155
+ noActivity: 'No activity',
156
+ chartCols: ['Day', 'Tokens', 'With cache', 'Calls', 'Per call'],
157
+ chartColsWeek: ['Week of', 'Tokens', 'With cache', 'Calls', 'Per call'],
151
158
  chartCalls: (n) => plural(n, 'call', 'calls'),
152
159
  weekOf: (d) => `week of ${d}`,
153
160
  withCacheShort: 'with cache',
@@ -155,7 +162,7 @@ Options:
155
162
  since: (date) => `active since ${date}`,
156
163
  olderSkipped: (n) => `${plural(n, 'older conversation', 'older conversations')} left out by the date filter`,
157
164
  periodLine: (period, since) => `Period: ${period}${since ? ` (filter: ${since})` : ''}`,
158
- footerReal: (missing) => `Real tokens: the ones Antigravity records in each conversation's log (subagents included)${missing ? `; ${plural(missing, 'conversation has', 'conversations have')} no log and ${missing === 1 ? 'is' : 'are'} not counted` : ''}.`,
165
+ footerReal: (missing) => `Real tokens: the ones Antigravity records in each conversation's log (subagents included)${missing ? `; ${plural(missing, 'conversation has', 'conversations have')} no token counts in its log and ${missing === 1 ? 'is' : 'are'} not counted` : ''}.`,
159
166
  footerCache: 'It is unknown whether the quota counts cache the same as new input, so both figures are shown.',
160
167
  footerEstimate: '~tokens = stored size of each step / 4: useful to compare files and folders with each other.',
161
168
  footerPrivacy: 'The history was read from a copy and was not modified. This page contains paths from your projects: review it before sharing.',
@@ -204,7 +211,7 @@ Opciones:
204
211
  mapDone: (tokens, file) => `\n~${tokens} tokens en cada petición. Pégalo en ${file} si te convence; agy-insights no lo escribe.`,
205
212
  reportName: 'informe',
206
213
  debugModels: {
207
- header: ['conversación', 'llamadas', 'filas gen_metadata', 'filas con modelo', 'llamadas con modelo en transcript', 'desconocidas', 'modelo principal', 'ids de modelo no reconocidos'],
214
+ header: ['conversación', 'llamadas', 'filas gen_metadata', 'filas con modelo', 'llamadas con modelo en transcript', 'desconocidas', 'modelo principal', 'ids de modelo no reconocidos', 'llamadas sin tokens', 'campos de esas llamadas'],
208
215
  total: 'total',
209
216
  note: 'Solo recuentos y nombres o ids de modelo: ni rutas, ni prompts, ni código. Pégalo en una incidencia si salen modelos desconocidos.',
210
217
  },
@@ -311,12 +318,19 @@ Opciones:
311
318
  filterLabel: 'Periodo',
312
319
  rangeAll: 'Todo el historial',
313
320
  rangeDays: (n) => `Últimos ${n} días`,
321
+ rangeWeek: 'Esta semana',
314
322
  chartTitleDay: 'Consumo de tokens por día',
315
323
  chartTitleWeek: 'Consumo de tokens por semana',
316
324
  chartHint: 'Tokens reales (entrada + salida) de las llamadas al modelo. Pasa el ratón por una columna para ver el detalle.',
317
325
  chartTable: 'Ver como tabla',
318
- chartCols: ['Día', 'Tokens', 'Con caché', 'Llamadas'],
319
- chartColsWeek: ['Semana del', 'Tokens', 'Con caché', 'Llamadas'],
326
+ chartModeLabel: 'Mostrar',
327
+ chartModeTotal: 'Total',
328
+ chartModeCall: 'Por llamada',
329
+ chartHintCall: 'Tokens medios (entrada + salida) por llamada al modelo. A diferencia del total, no depende de cuánto hayas usado el agente: baja cuando cada llamada lleva menos contexto (sesiones más cortas, salidas de comandos filtradas), así que muestra si un cambio ha funcionado.',
330
+ perCallTip: (n) => `${n} tokens por llamada`,
331
+ noActivity: 'Sin actividad',
332
+ chartCols: ['Día', 'Tokens', 'Con caché', 'Llamadas', 'Por llamada'],
333
+ chartColsWeek: ['Semana del', 'Tokens', 'Con caché', 'Llamadas', 'Por llamada'],
320
334
  chartCalls: (n) => plural(n, 'llamada', 'llamadas'),
321
335
  weekOf: (d) => `semana del ${d}`,
322
336
  withCacheShort: 'con caché',
@@ -324,7 +338,7 @@ Opciones:
324
338
  since: (date) => `con actividad desde el ${date}`,
325
339
  olderSkipped: (n) => `${plural(n, 'conversación anterior queda fuera', 'conversaciones anteriores quedan fuera')} por el filtro de fechas`,
326
340
  periodLine: (period, since) => `Periodo: ${period}${since ? ` (filtro: ${since})` : ''}`,
327
- footerReal: (missing) => `Tokens reales: los que Antigravity anota en el registro de cada conversación (incluidos subagentes)${missing ? `; ${missing === 1 ? '1 conversación no tiene registro y no cuenta' : `${missing} conversaciones no tienen registro y no cuentan`}` : ''}.`,
341
+ footerReal: (missing) => `Tokens reales: los que Antigravity anota en el registro de cada conversación (incluidos subagentes)${missing ? `; ${missing === 1 ? '1 conversación no tiene tokens en su registro y no cuenta' : `${missing} conversaciones no tienen tokens en su registro y no cuentan`}` : ''}.`,
328
342
  footerCache: 'No se sabe si la cuota cuenta la caché igual que la entrada nueva: por eso se muestran las dos cifras.',
329
343
  footerEstimate: '~tokens = tamaño guardado de cada paso / 4: sirve para comparar ficheros y carpetas entre sí.',
330
344
  footerPrivacy: '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.',
package/lib/report.js CHANGED
@@ -38,9 +38,12 @@ function item(it) {
38
38
  <span class="num">${it.tokens ? `${it.exact ? '' : '~'}${fmt(it.tokens)}` : ''}</span></div>${extra}</li>`;
39
39
  }
40
40
 
41
- function finding(f, total) {
41
+ // A finding's share: of real usage when its figure is real (`exact`), else of the estimated context.
42
+ const share = (f, r) => (f.exact ? billed(r.usage) : r.tokens);
43
+
44
+ function finding(f, r) {
42
45
  return `<section class="finding ${f.level}">
43
- <h3>${esc(f.title)}${f.tokens ? ` <span class="tok">~${fmt(f.tokens)} tokens · ${pct(f.tokens, total)}</span>` : ''}</h3>
46
+ <h3>${esc(f.title)}${f.tokens ? ` <span class="tok">${f.exact ? '' : '~'}${fmt(f.tokens)} tokens · ${pct(f.tokens, share(f, r))}</span>` : ''}</h3>
44
47
  <p>${esc(f.why)}</p><p class="hint">${esc(f.suggestion)}</p>
45
48
  ${f.items.length ? `<ul class="items">${f.items.map(item).join('')}</ul>` : ''}
46
49
  </section>`;
@@ -115,12 +118,33 @@ function buckets(r) {
115
118
  return { weekly, rows: [...byKey.values()] };
116
119
  }
117
120
 
121
+ // Mean tokens per call: unlike the total, it doesn't follow how much the agent was used that day.
122
+ const perCall = (x) => (x.calls ? x.billed / x.calls : 0);
123
+
118
124
  function chart(r) {
119
125
  const b = buckets(r);
120
126
  if (!b) return '';
127
+ const title = b.weekly ? t.report.chartTitleWeek : t.report.chartTitleDay;
128
+ const rows = b.rows.filter((x) => x.calls);
129
+ return `<section class="chart">
130
+ <h2>${title}</h2>
131
+ <div class="mode" role="group" aria-label="${esc(t.report.chartModeLabel)}">
132
+ <button type="button" data-mode="total" aria-pressed="true">${t.report.chartModeTotal}</button><button type="button" data-mode="call" aria-pressed="false">${t.report.chartModeCall}</button></div>
133
+ <p class="hint" data-mode="total">${t.report.chartHint}</p>
134
+ <p class="hint" data-mode="call" hidden>${t.report.chartHintCall}</p>
135
+ <div class="plot" data-mode="total">${plot(b, (x) => x.billed, (x) => `${fmt(x.billed)} tokens`, (x) => `${fmt(x.billed + x.cache)} ${t.report.withCacheShort}`, title)}</div>
136
+ <div class="plot" data-mode="call" hidden>${plot(b, perCall, (x) => t.report.perCallTip(fmt(perCall(x))), (x) => `${fmt(perCall(x) + x.cache / x.calls)} ${t.report.withCacheShort}`, `${title} · ${t.report.chartModeCall}`)}</div>
137
+ <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>
138
+ ${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><td class="num">${fmt(perCall(x))}</td></tr>`).join('')}
139
+ </tbody></table></div></details>
140
+ </section>`;
141
+ }
142
+
143
+ // One column chart of `value` per bucket; `tip` and `cache` are the tooltip's headline and cache line.
144
+ function plot(b, value, tip, cache, label) {
121
145
  const W = 900, H = 240, m = { l: 56, r: 8, t: 12, b: 28 };
122
146
  const pw = W - m.l - m.r, ph = H - m.t - m.b;
123
- const top = niceMax(Math.max(...b.rows.map((x) => x.billed)));
147
+ const top = niceMax(Math.max(...b.rows.map(value)));
124
148
  const band = pw / b.rows.length;
125
149
  const bw = Math.max(1, Math.min(24, band - 2));
126
150
  const y = (v) => m.t + ph - (v / top) * ph;
@@ -130,28 +154,20 @@ function chart(r) {
130
154
  <text x="${m.l - 8}" y="${y(v) + 4}" text-anchor="end" class="axis">${esc(compact(v))}</text>`).join('');
131
155
  // Rounded data end (4px), square at the baseline.
132
156
  const bars = b.rows.map((x, i) => {
133
- const h = (x.billed / top) * ph;
157
+ const h = (value(x) / top) * ph;
134
158
  if (h <= 0) return '';
135
159
  const x0 = m.l + i * band + (band - bw) / 2, y0 = m.t + ph - h, rr = Math.min(4, h, bw / 2);
136
160
  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"/>`;
137
161
  }).join('');
138
162
  // Hit targets: the whole band, so a 2px column is as easy to hover as a 24px one.
139
163
  const hits = b.rows.map((x, i) => `<rect class="hit" x="${m.l + i * band}" y="${m.t}" width="${band}" height="${ph}"
140
- 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('');
164
+ data-when="${esc(when(x))}" data-v="${esc(x.calls ? tip(x) : t.report.noActivity)}" data-c="${esc(x.calls ? cache(x) : '')}" data-n="${esc(x.calls ? t.report.chartCalls(x.calls) : '')}"/>`).join('');
141
165
  const every = Math.max(1, Math.ceil(b.rows.length / 6));
142
166
  // A date every few columns, plus the last one when it isn't crowded by the previous label.
143
167
  const last = b.rows.length - 1;
144
168
  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('');
145
169
 
146
- const rows = b.rows.filter((x) => x.calls);
147
- return `<section class="chart">
148
- <h2>${b.weekly ? t.report.chartTitleWeek : t.report.chartTitleDay}</h2>
149
- <p class="hint">${t.report.chartHint}</p>
150
- <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>
151
- <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>
152
- ${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('')}
153
- </tbody></table></div></details>
154
- </section>`;
170
+ return `<svg viewBox="0 0 ${W} ${H}" role="img" aria-label="${esc(label)}">${grid}${bars}${ticks}${hits}</svg>`;
155
171
  }
156
172
 
157
173
  // --- Page ---
@@ -175,7 +191,7 @@ function body(r, found, scope) {
175
191
  ${modelsTable(r)}
176
192
  <h2>${t.report.happening}</h2>
177
193
  <p class="hint">${t.report.happeningHint}</p>
178
- ${found.length ? found.map((f) => finding(f, r.tokens)).join('') : `<p>${t.report.nothing}</p>`}
194
+ ${found.length ? found.map((f) => finding(f, r)).join('') : `<p>${t.report.nothing}</p>`}
179
195
  <details class="detail-all"><summary><h2>${t.report.details}</h2></summary>
180
196
  ${sessionsTable(r)}
181
197
  ${table(t.report.byProject, r.projects, r.tokens, (x) => x.project || t.report.outsideGit)}
@@ -219,6 +235,12 @@ function htmlViews(views, { scope }) {
219
235
  .filter button:hover { background: var(--accent-soft); }
220
236
  .filter button[aria-pressed="true"] { background: var(--accent); border-color: var(--accent); color: var(--surface); font-weight: 600; }
221
237
  .filter button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
238
+ .mode { display: inline-flex; margin: 0 0 0 12px; vertical-align: 3px; border: 1px solid var(--line); border-radius: 6px; overflow: hidden; }
239
+ .mode button { font: 13px system-ui, sans-serif; color: var(--fg); background: var(--surface); border: 0; padding: 3px 10px; cursor: pointer; }
240
+ .mode button + button { border-left: 1px solid var(--line); }
241
+ .mode button:hover { background: var(--accent-soft); }
242
+ .mode button[aria-pressed="true"] { background: var(--accent-soft); color: var(--accent); font-weight: 600; }
243
+ .mode button:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
222
244
  .tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 12px; margin: 16px 0 20px; }
223
245
  .tiles div { background: var(--surface); border: 1px solid var(--line); border-radius: 8px; padding: 12px 14px; }
224
246
  .tiles b { display: block; font-size: 24px; font-variant-numeric: tabular-nums; } .tiles span { color: var(--muted); font-size: 14px; }
@@ -271,6 +293,13 @@ function htmlViews(views, { scope }) {
271
293
  for (const s of document.querySelectorAll('.view')) s.hidden = s.dataset.view !== v.dataset.view;
272
294
  return;
273
295
  }
296
+ // Chart mode: the same in every period, so switching periods keeps it.
297
+ const m = e.target.closest('.mode button');
298
+ if (m) {
299
+ for (const b of document.querySelectorAll('.mode button')) b.setAttribute('aria-pressed', String(b.dataset.mode === m.dataset.mode));
300
+ for (const p of document.querySelectorAll('.chart [data-mode]:not(button)')) p.hidden = p.dataset.mode !== m.dataset.mode;
301
+ return;
302
+ }
274
303
  const b = e.target.closest('.copy'); if (!b) return;
275
304
  const pre = b.parentElement.querySelector('pre');
276
305
  const ok = () => { b.textContent = ${JSON.stringify(t.report.copied)}; setTimeout(() => (b.textContent = ${JSON.stringify(t.report.copy)}), 1500); };
@@ -285,9 +314,10 @@ function htmlViews(views, { scope }) {
285
314
  const h = e.target.closest && e.target.closest('.hit');
286
315
  if (!h) { tip.hidden = true; return; }
287
316
  const [v, when, rest] = tip.children;
288
- v.textContent = h.dataset.v + ' tokens';
317
+ v.textContent = h.dataset.v;
289
318
  when.textContent = h.dataset.when;
290
- rest.textContent = h.dataset.n + ' · ' + h.dataset.c + ' ' + ${JSON.stringify(t.report.withCacheShort)};
319
+ rest.textContent = h.dataset.c ? h.dataset.n + ' · ' + h.dataset.c : h.dataset.n;
320
+ rest.hidden = !rest.textContent;
291
321
  tip.hidden = false;
292
322
  const x = Math.min(e.clientX + 14, innerWidth - tip.offsetWidth - 8);
293
323
  tip.style.left = x + 'px';
@@ -331,7 +361,7 @@ function text(r, found, { scope, paths }) {
331
361
  }
332
362
  }
333
363
  for (const f of found) {
334
- out.push('', `${f.level === 'warn' ? '⚠' : '•'} ${f.title}${f.tokens ? ` (~${fmt(f.tokens)} tokens, ${pct(f.tokens, r.tokens)})` : ''}`,
364
+ out.push('', `${f.level === 'warn' ? '⚠' : '•'} ${f.title}${f.tokens ? ` (${f.exact ? '' : '~'}${fmt(f.tokens)} tokens, ${pct(f.tokens, share(f, r))})` : ''}`,
335
365
  ` ${f.why}`, ` → ${f.suggestion}`);
336
366
  // Only file and folder labels are paths; commands and session ids are always shown.
337
367
  const hasPaths = ['rereads', 'non-source', 'cross-session'].includes(f.id);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agy-insights",
3
- "version": "0.7.3",
3
+ "version": "0.8.1",
4
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": [