agy-insights 0.7.2 → 0.8.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 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.2`.
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.0`.
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
  |---|---|---|
@@ -161,6 +161,8 @@ Copia `agy-insights.example.json` a `~/.agy-insights.json` (o pásalo con `--con
161
161
 
162
162
  **Uso la app o el IDE de Antigravity, no el CLI**. Por ahora solo se lee el historial del CLI.
163
163
 
164
+ **Muchas llamadas con modelo «desconocido»**. agy no anotó su modelo. Ejecuta `npx agy-insights@latest --debug-models` y pega el resultado en una [incidencia](https://github.com/migohe14/agy-insights/issues): solo contiene recuentos y nombres de modelo.
165
+
164
166
  **El navegador no se abre**. La ruta del informe aparece en la terminal; ábrela a mano.
165
167
 
166
168
  ## Estado
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.2`.
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.0`.
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
  |---|---|---|
@@ -161,6 +161,8 @@ Copy `agy-insights.example.json` to `~/.agy-insights.json` (or pass it with `--c
161
161
 
162
162
  **I use the Antigravity app or IDE, not the CLI**. For now only the CLI history is read.
163
163
 
164
+ **Many calls under model "unknown"**. agy recorded no model for them. Run `npx agy-insights@latest --debug-models` and paste the output into an [issue](https://github.com/migohe14/agy-insights/issues): it only has counts and model names.
165
+
164
166
  **The browser doesn't open**. The report path is printed in the terminal; open it manually.
165
167
 
166
168
  ## Status
package/agy-insights.js CHANGED
@@ -32,6 +32,7 @@ function parseArgs(argv) {
32
32
  else if (a === '--lang') opts.lang = value();
33
33
  else if (a === '--since') opts.since = value();
34
34
  else if (a === '--days') opts.days = value();
35
+ else if (a === '--debug-models') opts.debugModels = true;
35
36
  else fail(t.unknownOption(a));
36
37
  }
37
38
  return opts;
@@ -82,8 +83,30 @@ function sinceTime({ since, days }) {
82
83
  return null;
83
84
  }
84
85
 
86
+ // Tab-separated table of where each conversation's call models came from, for bug reports.
87
+ function debugModels(r) {
88
+ const d = t.debugModels;
89
+ const sum = { rows: 0, rowsWithModel: 0, calls: 0, fromTranscript: 0, unknown: 0 };
90
+ const lines = [d.header.join('\t')];
91
+ for (const s of r.sessions) {
92
+ const m = s.modelSources;
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'));
95
+ }
96
+ lines.push([d.total, sum.calls, sum.rows, sum.rowsWithModel, sum.fromTranscript, sum.unknown, '', ''].join('\t'), '', d.note, '');
97
+ return lines.join('\n');
98
+ }
99
+
85
100
  // Periods the HTML report can switch to, besides the whole history.
86
- const PRESET_DAYS = [90, 30, 7];
101
+ const PRESET_DAYS = [90, 30];
102
+
103
+ // This Monday at local midnight.
104
+ function weekStart() {
105
+ const d = new Date();
106
+ d.setHours(0, 0, 0, 0);
107
+ d.setDate(d.getDate() - ((d.getDay() + 6) % 7));
108
+ return d.getTime();
109
+ }
87
110
 
88
111
  async function main() {
89
112
  const argv = process.argv.slice(2);
@@ -104,6 +127,7 @@ async function main() {
104
127
  customKinds: cfg.customKinds || {},
105
128
  });
106
129
  const r = await run(since);
130
+ if (opts.debugModels) return process.stdout.write(debugModels(r));
107
131
  if (opts.map) {
108
132
  const m = buildMap(opts.project, { ...cfg.map, history: r.files });
109
133
  console.log(m.block);
@@ -128,6 +152,8 @@ async function main() {
128
152
  const x = await run(Date.now() - days * 24 * 60 * 60 * 1000);
129
153
  views.push({ key: `d${days}`, label: t.report.rangeDays(days), r: x, found: find(x) });
130
154
  }
155
+ const week = await run(weekStart());
156
+ views.push({ key: 'week', label: t.report.rangeWeek, r: week, found: find(week) });
131
157
  }
132
158
  fs.writeFileSync(out, report.htmlViews(views, { scope: opts.project }));
133
159
  console.log(t.done(r.conversations, found.length, out));
@@ -34,12 +34,12 @@ It is undocumented; this is what has been observed in `agy` 1.4.x.
34
34
  - One SQLite database per conversation: `~/.gemini/antigravity-cli/conversations/<uuid>.db`, in WAL mode.
35
35
  - Table `steps`: one row per step. `step_payload` is a protobuf message. Tool calls store the name (`view_file`, `run_command`, `grep_search`…) followed by their JSON arguments, for example `{"AbsolutePath":"C:\\dev\\...\\X.cs","StartLine":301,"EndLine":380}`, preceded by a `call_NNN` id.
36
36
  - Each call takes two steps with the same id: the model response that requests it (`step_type` 15) and the result (`step_type` 132), which repeats name and arguments and adds the file content or command output. `lib/history.js` counts the call once per id and adds the size of both steps, which approximates what entered the context.
37
- - Table `gen_metadata`: one row per model call, in the same order as the transcript's `PLANNER_RESPONSE` lines. Each row holds the model's display name with the effort level in brackets (`Gemini 3.8 Flash (High)`) and its id (`gemini-3.8-flash-high`). `modelOf()` takes the model and effort from whole strings only, so prose in the system prompt that mentions a model is ignored, and the n-th row is matched to the n-th call to get tokens per model and effort.
37
+ - Table `gen_metadata`: one row per model call, in the same order as the transcript's `PLANNER_RESPONSE` lines. Each row holds the model's display name with the effort level in brackets (`Gemini 3.8 Flash (High)`) and its id (`gemini-3.8-flash-high`). `modelOf()` takes the model and effort from whole strings only, so prose in the system prompt that mentions a model is ignored, and the n-th row is matched to the n-th call to get tokens per model and effort. That pairing is only trusted when the counts match. Otherwise each call takes the model selected in the transcript (`USER_INPUT` lines carry "changed setting `Model Selection` from X to Y"), or the conversation's only model; what is left is "unknown". `modelKey()` merges the two spellings (`Gemini 3.8 Flash` / `gemini-3.8-flash`, `High` / `high`). `--debug-models` prints, per conversation, how many calls got their model from each source: counts only, safe to paste into an issue.
38
38
  - Next to the history, `../brain/<id>/.system_generated/logs/transcript.jsonl`: one JSON line per step. `PLANNER_RESPONSE` lines are model calls and carry `input_tokens`, `cache_read_tokens` and `output_tokens`; failed steps have `status: "ERROR"`. Subagents have their own conversation and transcript. `realUsage()` in `lib/history.js` adds those figures up (it only reads the file). The total printed by `agy -p --output-format json` does not include subagents; this one does.
39
39
 
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` also has `last_modified_time` per conversation, but it is not used. Each model call's `created_at` also puts its tokens on a local day (`r.daily`), which feeds the usage chart.
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
@@ -131,7 +131,7 @@ function findings(r, opts = {}) {
131
131
  why: f.longWhy(long.length, num(o.longSessionTokens)), suggestion: f.longSuggestion,
132
132
  items: long.slice(0, 5).map((s) => ({
133
133
  // Real tokens when agy logged them (`exact`: shown without "~"), else the size estimate.
134
- label: f.conversation(s.id.slice(0, 8), when(s.start, s.end)), fixes: [],
134
+ label: f.conversation(s.id.slice(0, 8), when(s.start, s.end), s.title), fixes: [],
135
135
  tokens: s.usage ? s.usage.input + s.usage.output : s.tokens, exact: Boolean(s.usage),
136
136
  detail: f.steps(s.steps) + (s.usage
137
137
  ? f.longUsage(s.usage.calls, num(s.usage.input + s.usage.cache + s.usage.output))
package/lib/history.js CHANGED
@@ -29,6 +29,23 @@ function modelOf(strs) {
29
29
  return { model: null, effort: null };
30
30
  }
31
31
 
32
+ // agy notes model switches in the user's message: "changed setting `Model Selection` from None to
33
+ // Gemini 3.8 Flash (High)." The model in force for every call that follows.
34
+ const MODEL_SETTING_RE = /changed setting `Model Selection` from .+? to (.+?)\.(?:\s|$)/g;
35
+ function selectedModel(text) {
36
+ let last = null;
37
+ for (const m of String(text).matchAll(MODEL_SETTING_RE)) last = m[1].trim();
38
+ return last && modelOf([last]).model ? modelOf([last]) : null;
39
+ }
40
+
41
+ // A short string that may name a model in a format modelOf() does not know yet: mentions a model family,
42
+ // or is an enum-like id ("MODEL_PLACEHOLDER_M26"). Only used for --debug-models.
43
+ const looksLikeModel = (s) => s.length <= 60 && !/[\\/:]/.test(s)
44
+ && (/\b(gemini|claude|gpt|model|flash|pro|argon)\b/i.test(s) || /^[A-Z][A-Z0-9]*(_[A-Z0-9]+)+$/.test(s));
45
+
46
+ // "Gemini 3.8 Flash" and "gemini-3.8-flash", "High" and "high": the same model and effort.
47
+ const modelKey = (m) => `${(m.model || '').toLowerCase().replace(/[\s_]+/g, '-')}|${(m.effort || '').toLowerCase()}`;
48
+
32
49
  // --- SQLite access: node:sqlite when the runtime has it (Node >= 22.13), sql.js (WASM) otherwise. ---
33
50
 
34
51
  let nodeSqlite = null;
@@ -203,20 +220,27 @@ function realUsage(dir, id) {
203
220
  let text;
204
221
  try { text = fs.readFileSync(file, 'utf8'); } catch { return null; }
205
222
  // `days`: the same figures per local day of each model call (calls without a time go under null).
206
- // `perCall`: each call's figures, in order (they match gen_metadata's rows one to one).
223
+ // `perCall`: each call's figures, in order, and the model selected in the transcript when it was made.
207
224
  const u = { calls: 0, input: 0, cache: 0, output: 0, errors: 0, start: null, end: null, days: new Map(), perCall: [] };
225
+ let selected = null;
226
+ u.unrecognized = new Set();
208
227
  for (const line of text.split('\n')) {
209
228
  let e;
210
229
  try { e = JSON.parse(line); } catch { continue; }
211
230
  const at = Date.parse(e.created_at);
212
231
  if (at) { u.start = Math.min(u.start ?? at, at); u.end = Math.max(u.end ?? at, at); }
213
232
  if (e.status === 'ERROR') u.errors++;
233
+ if (e.type === 'USER_INPUT') {
234
+ selected = selectedModel(e.content) || selected;
235
+ // Model names agy selected that modelOf() does not understand, for --debug-models.
236
+ 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
+ }
214
238
  if (e.type !== 'PLANNER_RESPONSE') continue;
215
239
  u.calls++;
216
240
  u.input += Number(e.input_tokens) || 0;
217
241
  u.cache += Number(e.cache_read_tokens) || 0;
218
242
  u.output += Number(e.output_tokens) || 0;
219
- u.perCall.push({ input: Number(e.input_tokens) || 0, cache: Number(e.cache_read_tokens) || 0, 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 });
220
244
  const k = at ? dayKey(at) : null;
221
245
  const d = u.days.get(k) || { calls: 0, input: 0, cache: 0, output: 0 };
222
246
  d.calls++;
@@ -228,6 +252,26 @@ function realUsage(dir, id) {
228
252
  return u;
229
253
  }
230
254
 
255
+ // --- Titles ---
256
+
257
+ // The short title agy gives each conversation ("Analyzing Package Lock Dependencies"), from
258
+ // <conversations>/../conversation_summaries.db; older ones only have it as `preview`. Empty map when
259
+ // the file is missing or unreadable: the report then falls back to the conversation id.
260
+ async function conversationTitles(dir, tmp) {
261
+ const titles = new Map();
262
+ const file = path.join(dir, '..', 'conversation_summaries.db');
263
+ if (!fs.existsSync(file)) return titles;
264
+ let db;
265
+ try {
266
+ db = await openDb(file, tmp);
267
+ for (const [id, title, preview] of db.all('select conversation_id, title, preview from conversation_summaries')) {
268
+ const name = String(title || preview || '').trim().slice(0, 80);
269
+ if (name) titles.set(String(id), name);
270
+ }
271
+ } catch { /* no titles */ } finally { db?.close(); }
272
+ return titles;
273
+ }
274
+
231
275
  // --- Aggregation ---
232
276
 
233
277
  class Counter {
@@ -269,6 +313,7 @@ async function analyze({ dir = DEFAULT_DIR, workspaces = [], customKinds = {}, s
269
313
 
270
314
  try {
271
315
  const dbs = fs.existsSync(dir) ? fs.readdirSync(dir).filter((f) => /^[0-9a-f-]{36}\.db$/i.test(f)) : [];
316
+ const titles = await conversationTitles(dir, tmp);
272
317
  for (const f of dbs) {
273
318
  // When it happened: the transcript's step times, else the database's last write.
274
319
  const id = f.replace(/\.db$/, '');
@@ -345,26 +390,45 @@ async function analyze({ dir = DEFAULT_DIR, workspaces = [], customKinds = {}, s
345
390
  daily.set(day, d);
346
391
  }
347
392
  }
348
- // One gen_metadata row per model call, in the transcript's order: the n-th row's model made the n-th call.
349
- const callModels = db.all('select data from gen_metadata order by idx').map(([blob]) => modelOf(collect(blob)));
350
- const n = Math.max(callModels.length, usage ? usage.perCall.length : 0);
351
- const seenModels = new Set();
352
- for (let i = 0; i < n; i++) {
353
- const { model, effort } = callModels[i] || { model: null, effort: null };
354
- const k = `${model}|${effort}`;
355
- const e = models.get(k) || { model, effort, calls: 0, convs: 0, input: 0, cache: 0, output: 0, known: 0 };
393
+ // Model of each call. gen_metadata usually has one row per call, in the transcript's order; when the
394
+ // counts match, the n-th row is the n-th call. When they don't, pairing by position would mislabel
395
+ // calls, so the model selected in the transcript is used instead, or the conversation's only model.
396
+ const metaStrs = db.all('select data from gen_metadata order by idx').map(([blob]) => collect(blob));
397
+ const metaRows = metaStrs.map(modelOf);
398
+ const calls = usage ? usage.perCall : metaRows.map(() => null);
399
+ const aligned = metaRows.length === calls.length;
400
+ const rowModels = new Set(metaRows.filter((m) => m.model).map(modelKey));
401
+ const onlyModel = rowModels.size === 1 ? metaRows.find((m) => m.model) : null;
402
+ const callModels = calls.map((c, i) => {
403
+ if (aligned && metaRows[i].model) return metaRows[i];
404
+ return c?.model || onlyModel || { model: null, effort: null };
405
+ });
406
+ const seenModels = new Set(), counts = new Map();
407
+ callModels.forEach((m, i) => {
408
+ const k = modelKey(m);
409
+ const e = models.get(k) || { model: m.model, effort: m.effort, calls: 0, convs: 0, input: 0, cache: 0, output: 0, known: 0 };
410
+ // Prefer the display name ("Gemini 3.8 Flash", "High") over the id spelling.
411
+ if (m.model && /[A-Z ]/.test(m.model) && !/[A-Z ]/.test(e.model || '')) { e.model = m.model; e.effort = m.effort; }
356
412
  e.calls++;
357
413
  if (!seenModels.has(k)) { seenModels.add(k); e.convs++; }
358
- const c = usage?.perCall[i];
414
+ const c = calls[i];
359
415
  if (c) { e.known++; e.input += c.input; e.cache += c.cache; e.output += c.output; }
360
416
  models.set(k, e);
361
- }
417
+ if (m.model) counts.set(k, (counts.get(k) || 0) + 1);
418
+ });
362
419
  // The session's model: the one that made most of its calls.
363
- const counts = new Map();
364
- for (const m of callModels) counts.set(`${m.model}|${m.effort}`, (counts.get(`${m.model}|${m.effort}`) || 0) + 1);
365
420
  const main = [...counts].sort((a, b) => b[1] - a[1])[0];
366
- const model = main ? callModels.find((m) => `${m.model}|${m.effort}` === main[0]) : null;
367
- r.sessions.push({ id, start, end, steps: steps.length, tokens: convBytes / BYTES_PER_TOKEN, usage, model: model?.model ? model : null, models: counts.size });
421
+ const model = main ? models.get(main[0]) : null;
422
+ r.sessions.push({
423
+ id, title: titles.get(id) || null, start, end, steps: steps.length, tokens: convBytes / BYTES_PER_TOKEN, usage,
424
+ model: model ? { model: model.model, effort: model.effort } : null, models: counts.size,
425
+ // For --debug-models: where each call's model came from, and for rows where no model was recognized,
426
+ // the short strings that look like a model id (to spot a new format). Never prompts, paths or code.
427
+ modelSources: { rows: metaRows.length, rowsWithModel: metaRows.filter((m) => m.model).length, calls: calls.length,
428
+ fromTranscript: calls.filter((c) => c?.model).length, unknown: callModels.filter((m) => !m.model).length,
429
+ candidates: [...new Set([...(usage?.unrecognized || []),
430
+ ...metaStrs.filter((_, i) => !metaRows[i].model).flat().filter(looksLikeModel)])].slice(0, 10) },
431
+ });
368
432
  r.tokens += convBytes / BYTES_PER_TOKEN;
369
433
  } catch { r.unreadable++; } finally { db.close(); }
370
434
  }
@@ -386,4 +450,4 @@ async function analyze({ dir = DEFAULT_DIR, workspaces = [], customKinds = {}, s
386
450
  };
387
451
  }
388
452
 
389
- module.exports = { analyze, DEFAULT_DIR, kind, NON_SOURCE, realUsage, dayKey, modelOf, repoFiles };
453
+ module.exports = { analyze, DEFAULT_DIR, kind, NON_SOURCE, realUsage, dayKey, modelOf, selectedModel, repoFiles };
package/lib/i18n.js CHANGED
@@ -20,6 +20,7 @@ Options:
20
20
  --since <YYYY-MM-DD> Only conversations active since that date
21
21
  --days <n> Only conversations active in the last n days
22
22
  --lang <en|es> Language of the output (default: the system's)
23
+ --debug-models Per conversation, where each call's model came from (counts only, to report issues)
23
24
  --dir <folder> Another history folder (default ${dir})
24
25
  --config <file> Configuration (default ~/.agy-insights.json if it exists)
25
26
  --help This help
@@ -33,6 +34,11 @@ Options:
33
34
  cannotRead: (f, msg) => `Cannot read ${f}: ${msg}`,
34
35
  mapDone: (tokens, file) => `\n~${tokens} tokens on every request. Paste it into ${file} if you like it; agy-insights does not write it.`,
35
36
  reportName: 'report',
37
+ debugModels: {
38
+ header: ['conversation', 'calls', 'gen_metadata rows', 'rows with model', 'calls with model in transcript', 'unknown', 'main model', 'unrecognized model ids'],
39
+ total: 'total',
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
+ },
36
42
  done: (convs, found, out) => `${plural(convs, 'conversation', 'conversations')} analyzed, ${plural(found, 'finding', 'findings')}.\nReport: ${out}`,
37
43
 
38
44
  kinds: {
@@ -81,7 +87,7 @@ Options:
81
87
  longTitle: 'Very long sessions',
82
88
  longWhy: (n, min) => `${plural(n, 'conversation exceeds', 'conversations exceed')} ${min} tokens. In a long session, everything accumulated is resent on every turn.`,
83
89
  longSuggestion: 'A new conversation per task, or /compact when you change topic.',
84
- conversation: (id, at) => `conversation ${id}${at ? ` · ${at}` : ''}`,
90
+ conversation: (id, at, title) => `${title ? `"${title}" (${id})` : `conversation ${id}`}${at ? ` · ${at}` : ''}`,
85
91
  steps: (n) => plural(n, 'step', 'steps'),
86
92
  longUsage: (calls, cached) => ` · ${plural(calls, 'model call', 'model calls')} · real tokens, ${cached} with cache`,
87
93
  },
@@ -112,6 +118,7 @@ Options:
112
118
  modelLine: (model, effort, calls, tokens, pct, perCall) => `${model} · ${effort} ${plural(calls, 'call', 'calls')}, ${tokens} tokens (${pct}), ~${perCall} per call`,
113
119
  sessionModel: 'Model',
114
120
  otherModels: (n) => ` +${n} more`,
121
+ modelsUnknown: (n) => `"unknown": ${n} calls for which agy recorded no model, neither in gen_metadata nor as a model change in the transcript. If there are many, run npx agy-insights@latest --debug-models and paste the result into an issue on GitHub (it only contains counts).`,
115
122
  efforts: { minimal: 'Minimal', low: 'Low', medium: 'Medium', high: 'High', xhigh: 'Extra high', 'extra high': 'Extra high', max: 'Max' },
116
123
  sessionsTitle: 'Sessions (real usage)',
117
124
  sessionCols: ['Conversation', 'When', 'Model', 'Tokens', '%', 'With cache', 'Calls', 'Errors'],
@@ -135,12 +142,19 @@ Options:
135
142
  filterLabel: 'Period',
136
143
  rangeAll: 'All history',
137
144
  rangeDays: (n) => `Last ${n} days`,
145
+ rangeWeek: 'This week',
138
146
  chartTitleDay: 'Token usage per day',
139
147
  chartTitleWeek: 'Token usage per week',
140
148
  chartHint: 'Real tokens (input + output) of the model calls. Hover a column for details.',
141
149
  chartTable: 'View as table',
142
- chartCols: ['Day', 'Tokens', 'With cache', 'Calls'],
143
- 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'],
144
158
  chartCalls: (n) => plural(n, 'call', 'calls'),
145
159
  weekOf: (d) => `week of ${d}`,
146
160
  withCacheShort: 'with cache',
@@ -158,7 +172,7 @@ Options:
158
172
  realUsage: (real, input, output, cached, calls) => `Real usage: ${real} tokens (input ${input} + output ${output}), ${cached} including cache, in ${plural(calls, 'model call', 'model calls')}`,
159
173
  noLog: (n) => ` (${plural(n, 'conversation', 'conversations')} without a log)`,
160
174
  topSessions: (n) => (n === 1 ? ' The session that uses the most:' : ` The ${n} sessions that use the most:`),
161
- sessionLine: (id, calls, errors, cached, model, at) => `conversation ${id}${at ? ` · ${at}` : ''}${model ? ` (${model})` : ''} ${plural(calls, 'call', 'calls')}${errors ? `, ${plural(errors, 'error', 'errors')}` : ''}, ${cached} with cache`,
175
+ sessionLine: (id, calls, errors, cached, model, at, title) => `${title ? `"${title}" (${id})` : `conversation ${id}`}${at ? ` · ${at}` : ''}${model ? ` (${model})` : ''} ${plural(calls, 'call', 'calls')}${errors ? `, ${plural(errors, 'error', 'errors')}` : ''}, ${cached} with cache`,
162
176
  top5: (pct, reads, rereads, rpct) => `The 5 largest sessions account for ${pct}. Reads: ${reads}, re-reads ${rereads} (${rpct})`,
163
177
  pathHidden: '(path hidden, use --paths)',
164
178
  byKindText: 'By file type:',
@@ -182,6 +196,7 @@ Opciones:
182
196
  --since <AAAA-MM-DD> Solo conversaciones con actividad desde esa fecha
183
197
  --days <n> Solo conversaciones con actividad en los últimos n días
184
198
  --lang <en|es> Idioma de la salida (por defecto, el del sistema)
199
+ --debug-models Por conversación, de dónde sale el modelo de cada llamada (solo recuentos, para reportar fallos)
185
200
  --dir <carpeta> Otra carpeta de historial (por defecto ${dir})
186
201
  --config <fichero> Configuración (por defecto ~/.agy-insights.json si existe)
187
202
  --help Esta ayuda
@@ -195,6 +210,11 @@ Opciones:
195
210
  cannotRead: (f, msg) => `No se puede leer ${f}: ${msg}`,
196
211
  mapDone: (tokens, file) => `\n~${tokens} tokens en cada petición. Pégalo en ${file} si te convence; agy-insights no lo escribe.`,
197
212
  reportName: 'informe',
213
+ debugModels: {
214
+ header: ['conversación', 'llamadas', 'filas gen_metadata', 'filas con modelo', 'llamadas con modelo en transcript', 'desconocidas', 'modelo principal', 'ids de modelo no reconocidos'],
215
+ total: 'total',
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.',
217
+ },
198
218
  done: (convs, found, out) => `${plural(convs, 'conversación analizada', 'conversaciones analizadas')}, ${plural(found, 'hallazgo', 'hallazgos')}.\nInforme: ${out}`,
199
219
 
200
220
  kinds: {
@@ -243,7 +263,7 @@ Opciones:
243
263
  longTitle: 'Sesiones muy largas',
244
264
  longWhy: (n, min) => `${plural(n, 'conversación supera', 'conversaciones superan')} ${min} tokens. En una sesión larga, todo lo acumulado se vuelve a enviar en cada turno.`,
245
265
  longSuggestion: 'Una conversación nueva por tarea, o /compact cuando cambias de tema.',
246
- conversation: (id, at) => `conversación ${id}${at ? ` · ${at}` : ''}`,
266
+ conversation: (id, at, title) => `${title ? `«${title}» (${id})` : `conversación ${id}`}${at ? ` · ${at}` : ''}`,
247
267
  steps: (n) => plural(n, 'paso', 'pasos'),
248
268
  longUsage: (calls, cached) => ` · ${plural(calls, 'llamada', 'llamadas')} al modelo · tokens reales, ${cached} con caché`,
249
269
  },
@@ -274,6 +294,7 @@ Opciones:
274
294
  modelLine: (model, effort, calls, tokens, pct, perCall) => `${model} · ${effort} ${plural(calls, 'llamada', 'llamadas')}, ${tokens} tokens (${pct}), ~${perCall} por llamada`,
275
295
  sessionModel: 'Modelo',
276
296
  otherModels: (n) => ` +${n} más`,
297
+ modelsUnknown: (n) => `«desconocido»: ${n} llamadas de las que agy no anotó el modelo, ni en gen_metadata ni como cambio de modelo en el transcript. Si son muchas, ejecuta npx agy-insights@latest --debug-models y pega el resultado en una incidencia de GitHub (solo contiene recuentos).`,
277
298
  efforts: { minimal: 'Mínimo', low: 'Bajo', medium: 'Medio', high: 'Alto', xhigh: 'Muy alto', 'extra high': 'Muy alto', max: 'Máximo' },
278
299
  sessionsTitle: 'Sesiones (consumo real)',
279
300
  sessionCols: ['Conversación', 'Cuándo', 'Modelo', 'Tokens', '%', 'Con caché', 'Llamadas', 'Errores'],
@@ -297,12 +318,19 @@ Opciones:
297
318
  filterLabel: 'Periodo',
298
319
  rangeAll: 'Todo el historial',
299
320
  rangeDays: (n) => `Últimos ${n} días`,
321
+ rangeWeek: 'Esta semana',
300
322
  chartTitleDay: 'Consumo de tokens por día',
301
323
  chartTitleWeek: 'Consumo de tokens por semana',
302
324
  chartHint: 'Tokens reales (entrada + salida) de las llamadas al modelo. Pasa el ratón por una columna para ver el detalle.',
303
325
  chartTable: 'Ver como tabla',
304
- chartCols: ['Día', 'Tokens', 'Con caché', 'Llamadas'],
305
- 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'],
306
334
  chartCalls: (n) => plural(n, 'llamada', 'llamadas'),
307
335
  weekOf: (d) => `semana del ${d}`,
308
336
  withCacheShort: 'con caché',
@@ -319,7 +347,7 @@ Opciones:
319
347
  realUsage: (real, input, output, cached, calls) => `Consumo real: ${real} tokens (entrada ${input} + salida ${output}), ${cached} contando la caché, en ${plural(calls, 'llamada', 'llamadas')} al modelo`,
320
348
  noLog: (n) => ` (${plural(n, 'conversación', 'conversaciones')} sin registro)`,
321
349
  topSessions: (n) => (n === 1 ? ' La sesión que más gasta:' : ` Las ${n} sesiones que más gastan:`),
322
- sessionLine: (id, calls, errors, cached, model, at) => `conversación ${id}${at ? ` · ${at}` : ''}${model ? ` (${model})` : ''} ${plural(calls, 'llamada', 'llamadas')}${errors ? `, ${plural(errors, 'error', 'errores')}` : ''}, ${cached} con caché`,
350
+ sessionLine: (id, calls, errors, cached, model, at, title) => `${title ? `«${title}» (${id})` : `conversación ${id}`}${at ? ` · ${at}` : ''}${model ? ` (${model})` : ''} ${plural(calls, 'llamada', 'llamadas')}${errors ? `, ${plural(errors, 'error', 'errores')}` : ''}, ${cached} con caché`,
323
351
  top5: (pct, reads, rereads, rpct) => `Las 5 sesiones más grandes suman el ${pct}. Lecturas: ${reads}, relecturas ${rereads} (${rpct})`,
324
352
  pathHidden: '(ruta oculta, usa --paths)',
325
353
  byKindText: 'Por tipo de fichero:',
package/lib/report.js CHANGED
@@ -60,7 +60,7 @@ function sessionsTable(r) {
60
60
  if (!rows.length) return '';
61
61
  const max = Math.max(...rows.map((s) => billed(s.usage)), 1);
62
62
  return `<h3>${t.report.sessionsTitle}</h3><div class="scroll"><table><thead><tr>${t.report.sessionCols.map((c, i) => `<th${i > 2 ? ' 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 class="when">${esc(when(s.start, s.end))}</td><td>${esc(sessionModel(s))}</td>
63
+ ${rows.map((s) => `<tr><td><div class="bar" style="--w:${((100 * billed(s.usage)) / max).toFixed(1)}%"></div><span>${esc(s.title || s.id.slice(0, 8))}</span>${s.title ? `<span class="detail">${esc(s.id.slice(0, 8))}</span>` : ''}</td><td class="when">${esc(when(s.start, s.end))}</td><td>${esc(sessionModel(s))}</td>
64
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>
65
65
  <td class="num">${s.usage.calls}</td><td class="num">${s.usage.errors || ''}</td></tr>`).join('')}
66
66
  </tbody></table></div>`;
@@ -76,12 +76,13 @@ function modelsTable(r) {
76
76
  if (!rows.length || (rows.length === 1 && !rows[0].model)) return '';
77
77
  const total = rows.reduce((s, m) => s + m.input + m.output, 0);
78
78
  const max = Math.max(...rows.map((m) => m.input + m.output), 1);
79
+ const unknown = rows.find((m) => !m.model);
79
80
  return `<section class="models"><h2>${t.report.modelsTitle}</h2><p class="hint">${t.report.modelsHint}</p>
80
81
  <div class="scroll"><table><thead><tr>${t.report.modelCols.map((c, i) => `<th${i > 1 ? ' class="num"' : ''}>${c}</th>`).join('')}</tr></thead><tbody>
81
82
  ${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
83
  <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
84
  <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
+ </tbody></table></div>${unknown ? `<p class="hint">${esc(t.report.modelsUnknown(fmt(unknown.calls)))}</p>` : ''}</section>`;
85
86
  }
86
87
 
87
88
  // --- Usage chart ---
@@ -114,12 +115,33 @@ function buckets(r) {
114
115
  return { weekly, rows: [...byKey.values()] };
115
116
  }
116
117
 
118
+ // Mean tokens per call: unlike the total, it doesn't follow how much the agent was used that day.
119
+ const perCall = (x) => (x.calls ? x.billed / x.calls : 0);
120
+
117
121
  function chart(r) {
118
122
  const b = buckets(r);
119
123
  if (!b) return '';
124
+ const title = b.weekly ? t.report.chartTitleWeek : t.report.chartTitleDay;
125
+ const rows = b.rows.filter((x) => x.calls);
126
+ return `<section class="chart">
127
+ <h2>${title}</h2>
128
+ <div class="mode" role="group" aria-label="${esc(t.report.chartModeLabel)}">
129
+ <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>
130
+ <p class="hint" data-mode="total">${t.report.chartHint}</p>
131
+ <p class="hint" data-mode="call" hidden>${t.report.chartHintCall}</p>
132
+ <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>
133
+ <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>
134
+ <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>
135
+ ${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('')}
136
+ </tbody></table></div></details>
137
+ </section>`;
138
+ }
139
+
140
+ // One column chart of `value` per bucket; `tip` and `cache` are the tooltip's headline and cache line.
141
+ function plot(b, value, tip, cache, label) {
120
142
  const W = 900, H = 240, m = { l: 56, r: 8, t: 12, b: 28 };
121
143
  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)));
144
+ const top = niceMax(Math.max(...b.rows.map(value)));
123
145
  const band = pw / b.rows.length;
124
146
  const bw = Math.max(1, Math.min(24, band - 2));
125
147
  const y = (v) => m.t + ph - (v / top) * ph;
@@ -129,28 +151,20 @@ function chart(r) {
129
151
  <text x="${m.l - 8}" y="${y(v) + 4}" text-anchor="end" class="axis">${esc(compact(v))}</text>`).join('');
130
152
  // Rounded data end (4px), square at the baseline.
131
153
  const bars = b.rows.map((x, i) => {
132
- const h = (x.billed / top) * ph;
154
+ const h = (value(x) / top) * ph;
133
155
  if (h <= 0) return '';
134
156
  const x0 = m.l + i * band + (band - bw) / 2, y0 = m.t + ph - h, rr = Math.min(4, h, bw / 2);
135
157
  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
158
  }).join('');
137
159
  // Hit targets: the whole band, so a 2px column is as easy to hover as a 24px one.
138
160
  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('');
161
+ 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('');
140
162
  const every = Math.max(1, Math.ceil(b.rows.length / 6));
141
163
  // A date every few columns, plus the last one when it isn't crowded by the previous label.
142
164
  const last = b.rows.length - 1;
143
165
  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
166
 
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>`;
167
+ return `<svg viewBox="0 0 ${W} ${H}" role="img" aria-label="${esc(label)}">${grid}${bars}${ticks}${hits}</svg>`;
154
168
  }
155
169
 
156
170
  // --- Page ---
@@ -218,6 +232,12 @@ function htmlViews(views, { scope }) {
218
232
  .filter button:hover { background: var(--accent-soft); }
219
233
  .filter button[aria-pressed="true"] { background: var(--accent); border-color: var(--accent); color: var(--surface); font-weight: 600; }
220
234
  .filter button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
235
+ .mode { display: inline-flex; margin: 0 0 0 12px; vertical-align: 3px; border: 1px solid var(--line); border-radius: 6px; overflow: hidden; }
236
+ .mode button { font: 13px system-ui, sans-serif; color: var(--fg); background: var(--surface); border: 0; padding: 3px 10px; cursor: pointer; }
237
+ .mode button + button { border-left: 1px solid var(--line); }
238
+ .mode button:hover { background: var(--accent-soft); }
239
+ .mode button[aria-pressed="true"] { background: var(--accent-soft); color: var(--accent); font-weight: 600; }
240
+ .mode button:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
221
241
  .tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 12px; margin: 16px 0 20px; }
222
242
  .tiles div { background: var(--surface); border: 1px solid var(--line); border-radius: 8px; padding: 12px 14px; }
223
243
  .tiles b { display: block; font-size: 24px; font-variant-numeric: tabular-nums; } .tiles span { color: var(--muted); font-size: 14px; }
@@ -270,6 +290,13 @@ function htmlViews(views, { scope }) {
270
290
  for (const s of document.querySelectorAll('.view')) s.hidden = s.dataset.view !== v.dataset.view;
271
291
  return;
272
292
  }
293
+ // Chart mode: the same in every period, so switching periods keeps it.
294
+ const m = e.target.closest('.mode button');
295
+ if (m) {
296
+ for (const b of document.querySelectorAll('.mode button')) b.setAttribute('aria-pressed', String(b.dataset.mode === m.dataset.mode));
297
+ for (const p of document.querySelectorAll('.chart [data-mode]:not(button)')) p.hidden = p.dataset.mode !== m.dataset.mode;
298
+ return;
299
+ }
273
300
  const b = e.target.closest('.copy'); if (!b) return;
274
301
  const pre = b.parentElement.querySelector('pre');
275
302
  const ok = () => { b.textContent = ${JSON.stringify(t.report.copied)}; setTimeout(() => (b.textContent = ${JSON.stringify(t.report.copy)}), 1500); };
@@ -284,9 +311,10 @@ function htmlViews(views, { scope }) {
284
311
  const h = e.target.closest && e.target.closest('.hit');
285
312
  if (!h) { tip.hidden = true; return; }
286
313
  const [v, when, rest] = tip.children;
287
- v.textContent = h.dataset.v + ' tokens';
314
+ v.textContent = h.dataset.v;
288
315
  when.textContent = h.dataset.when;
289
- rest.textContent = h.dataset.n + ' · ' + h.dataset.c + ' ' + ${JSON.stringify(t.report.withCacheShort)};
316
+ rest.textContent = h.dataset.c ? h.dataset.n + ' · ' + h.dataset.c : h.dataset.n;
317
+ rest.hidden = !rest.textContent;
290
318
  tip.hidden = false;
291
319
  const x = Math.min(e.clientX + 14, innerWidth - tip.offsetWidth - 8);
292
320
  tip.style.left = x + 'px';
@@ -316,7 +344,7 @@ function text(r, found, { scope, paths }) {
316
344
  const top = r.sessions.filter((s) => s.usage).slice(0, 5);
317
345
  out.push(t.report.topSessions(top.length));
318
346
  for (const s of top) {
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), when(s.start, s.end))}`);
347
+ 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), when(s.start, s.end), s.title)}`);
320
348
  }
321
349
  }
322
350
  out.push(t.report.top5(pct(top5, r.tokens), r.reads, r.rereads, pct(r.rereads, r.reads)));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agy-insights",
3
- "version": "0.7.2",
3
+ "version": "0.8.0",
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": [