agy-insights 0.4.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 CHANGED
@@ -10,8 +10,8 @@ Te muestra qué contexto le estás mandando a **Antigravity** de forma repetida
10
10
  - Funciona con cualquier editor, en Windows, macOS y Linux.
11
11
 
12
12
  ```sh
13
- npx agy-insights # informe HTML en el navegador
14
- npx agy-insights --text # resumen en la terminal
13
+ npx agy-insights@latest # informe HTML en el navegador
14
+ npx agy-insights@latest --text # resumen en la terminal
15
15
  ```
16
16
 
17
17
  Solo necesitas Node.js 20 o superior y haber usado Antigravity CLI (`agy`) en el equipo. La salida sale en español o en inglés según el idioma del sistema; puedes forzarlo con `--lang es` o `--lang en`.
@@ -33,10 +33,10 @@ No tiene dependencias. Elige una forma:
33
33
 
34
34
  ```sh
35
35
  npx agy-insights
36
- npx agy-insights --text
36
+ npx agy-insights@latest --text
37
37
  ```
38
38
 
39
- Para fijar una versión: `npx agy-insights@0.4.0`.
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.0`.
40
40
 
41
41
  **Instalándolo globalmente:**
42
42
 
@@ -45,13 +45,15 @@ npm install -g agy-insights
45
45
  agy-insights
46
46
  ```
47
47
 
48
+ Para actualizarlo: `npm install -g agy-insights@latest`.
49
+
48
50
  **Clonando el repositorio:**
49
51
 
50
52
  ```sh
51
53
  git clone https://github.com/migohe14/agy-insights.git
52
54
  ```
53
55
 
54
- Y ejecútalo con `node agy-insights/agy-insights.js` en lugar de `npx agy-insights`. Para actualizar: `git pull` dentro de la carpeta.
56
+ Y ejecútalo con `node agy-insights/agy-insights.js` en lugar de `npx agy-insights@latest`. Para actualizar: `git pull` dentro de la carpeta.
55
57
 
56
58
  ## Usar
57
59
 
@@ -59,18 +61,19 @@ Y ejecútalo con `node agy-insights/agy-insights.js` en lugar de `npx agy-insigh
59
61
  npx agy-insights
60
62
  ```
61
63
 
62
- Analiza todo tu historial, guarda un informe HTML en la carpeta temporal y lo abre en el navegador.
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.
63
65
 
64
66
  | Para… | Ejecuta |
65
67
  |---|---|
66
- | Ver solo un proyecto | `npx agy-insights --project C:\dev\mi-proyecto` |
67
- | Ver el proyecto de la carpeta actual | `npx agy-insights --project .` |
68
- | Un resumen en la terminal | `npx agy-insights --text` |
69
- | El resumen con rutas y exclusiones | `npx agy-insights --text --paths` |
70
- | Un mapa del proyecto para pegar en `AGENTS.md` (carpetas, ficheros grandes, carpetas generadas; ~20 líneas aunque el repo sea grande) | `npx agy-insights --map --project <carpeta>` |
71
- | Elegir el idioma (español o inglés) | `npx agy-insights --lang es` |
72
- | Guardar el informe en otro sitio | `npx agy-insights --out informe.html --no-open` |
73
- | Ver todas las opciones | `npx agy-insights --help` |
68
+ | Ver solo un proyecto | `npx agy-insights@latest --project C:\dev\mi-proyecto` |
69
+ | Ver el proyecto de la carpeta actual | `npx agy-insights@latest --project .` |
70
+ | Un resumen en la terminal | `npx agy-insights@latest --text` |
71
+ | El resumen con rutas y exclusiones | `npx agy-insights@latest --text --paths` |
72
+ | Un mapa del proyecto para pegar en `AGENTS.md` (carpetas, ficheros grandes, carpetas generadas; ~20 líneas aunque el repo sea grande) | `npx agy-insights@latest --map --project <carpeta>` |
73
+ | Solo un periodo a medida (el informe ya no tiene selector de periodo) | `npx agy-insights@latest --since 2026-10-01` o `--days 14` |
74
+ | Elegir el idioma (español o inglés) | `npx agy-insights@latest --lang es` |
75
+ | Guardar el informe en otro sitio | `npx agy-insights@latest --out informe.html --no-open` |
76
+ | Ver todas las opciones | `npx agy-insights@latest --help` |
74
77
 
75
78
  El informe da dos cifras:
76
79
 
@@ -79,7 +82,7 @@ El informe da dos cifras:
79
82
 
80
83
  ## Leer el informe
81
84
 
82
- Arriba están los totales. 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; 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`.
83
86
 
84
87
  | Hallazgo | Qué significa | Qué hacer |
85
88
  |---|---|---|
package/README.md CHANGED
@@ -10,8 +10,8 @@ Shows which context you are sending to **Antigravity** repeatedly or needlessly,
10
10
  - Works with any editor, on Windows, macOS and Linux.
11
11
 
12
12
  ```sh
13
- npx agy-insights # HTML report in the browser
14
- npx agy-insights --text # summary in the terminal
13
+ npx agy-insights@latest # HTML report in the browser
14
+ npx agy-insights@latest --text # summary in the terminal
15
15
  ```
16
16
 
17
17
  All you need is Node.js 20 or later and to have used Antigravity CLI (`agy`) on this machine. The output is in English or Spanish, following your system language; force it with `--lang en` or `--lang es`.
@@ -33,10 +33,10 @@ It has no dependencies. Pick one:
33
33
 
34
34
  ```sh
35
35
  npx agy-insights
36
- npx agy-insights --text
36
+ npx agy-insights@latest --text
37
37
  ```
38
38
 
39
- To pin a version: `npx agy-insights@0.4.0`.
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.0`.
40
40
 
41
41
  **Globally:**
42
42
 
@@ -45,13 +45,15 @@ npm install -g agy-insights
45
45
  agy-insights
46
46
  ```
47
47
 
48
+ To update it: `npm install -g agy-insights@latest`.
49
+
48
50
  **From the repository:**
49
51
 
50
52
  ```sh
51
53
  git clone https://github.com/migohe14/agy-insights.git
52
54
  ```
53
55
 
54
- Then run it with `node agy-insights/agy-insights.js` instead of `npx agy-insights`. To update: `git pull` inside the folder.
56
+ Then run it with `node agy-insights/agy-insights.js` instead of `npx agy-insights@latest`. To update: `git pull` inside the folder.
55
57
 
56
58
  ## Usage
57
59
 
@@ -59,18 +61,19 @@ Then run it with `node agy-insights/agy-insights.js` instead of `npx agy-insight
59
61
  npx agy-insights
60
62
  ```
61
63
 
62
- Analyzes your whole history, saves an HTML report in the temp folder and opens it in the browser.
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.
63
65
 
64
66
  | To… | Run |
65
67
  |---|---|
66
- | See only one project | `npx agy-insights --project C:\dev\my-project` |
67
- | See the project in the current folder | `npx agy-insights --project .` |
68
- | Get a summary in the terminal | `npx agy-insights --text` |
69
- | Get the summary with paths and exclusions | `npx agy-insights --text --paths` |
70
- | Get a project map to paste into `AGENTS.md` (folders, large files, generated folders; ~20 lines even for a large repo) | `npx agy-insights --map --project <folder>` |
71
- | Choose the language (English or Spanish) | `npx agy-insights --lang es` |
72
- | Save the report somewhere else | `npx agy-insights --out report.html --no-open` |
73
- | See all options | `npx agy-insights --help` |
68
+ | See only one project | `npx agy-insights@latest --project C:\dev\my-project` |
69
+ | See the project in the current folder | `npx agy-insights@latest --project .` |
70
+ | Get a summary in the terminal | `npx agy-insights@latest --text` |
71
+ | Get the summary with paths and exclusions | `npx agy-insights@latest --text --paths` |
72
+ | Get a project map to paste into `AGENTS.md` (folders, large files, generated folders; ~20 lines even for a large repo) | `npx agy-insights@latest --map --project <folder>` |
73
+ | Only a custom period (the report then has no period selector) | `npx agy-insights@latest --since 2026-10-01` or `--days 14` |
74
+ | Choose the language (English or Spanish) | `npx agy-insights@latest --lang es` |
75
+ | Save the report somewhere else | `npx agy-insights@latest --out report.html --no-open` |
76
+ | See all options | `npx agy-insights@latest --help` |
74
77
 
75
78
  The report gives two figures:
76
79
 
@@ -79,7 +82,7 @@ The report gives two figures:
79
82
 
80
83
  ## Reading the report
81
84
 
82
- The totals are at the top. 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; 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.
83
86
 
84
87
  | Finding | What it means | What to do |
85
88
  |---|---|---|
package/agy-insights.js CHANGED
@@ -30,6 +30,8 @@ function parseArgs(argv) {
30
30
  else if (a === '--dir') opts.dir = path.resolve(value());
31
31
  else if (a === '--config') opts.config = path.resolve(value());
32
32
  else if (a === '--lang') opts.lang = value();
33
+ else if (a === '--since') opts.since = value();
34
+ else if (a === '--days') opts.days = value();
33
35
  else fail(t.unknownOption(a));
34
36
  }
35
37
  return opts;
@@ -65,6 +67,24 @@ function openInBrowser(file) {
65
67
  } catch { /* the path is printed anyway */ }
66
68
  }
67
69
 
70
+ // --since YYYY-MM-DD (local midnight) or --days n (n days back from now), as a time in ms; null for all.
71
+ function sinceTime({ since, days }) {
72
+ if (since) {
73
+ const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(since);
74
+ const d = m && new Date(+m[1], m[2] - 1, +m[3]);
75
+ if (!d || d.getMonth() !== m[2] - 1) fail(t.badDate(since));
76
+ return d.getTime();
77
+ }
78
+ if (days !== undefined) {
79
+ if (!/^\d+$/.test(days)) fail(t.badDays(days));
80
+ return Date.now() - Number(days) * 24 * 60 * 60 * 1000;
81
+ }
82
+ return null;
83
+ }
84
+
85
+ // Periods the HTML report can switch to, besides the whole history.
86
+ const PRESET_DAYS = [90, 30, 7];
87
+
68
88
  async function main() {
69
89
  const argv = process.argv.slice(2);
70
90
  // Language first, so that option errors are already in it.
@@ -76,28 +96,40 @@ async function main() {
76
96
  if (!opts.lang && cfg.lang && !setLang(cfg.lang)) fail(t.badLang(cfg.lang));
77
97
 
78
98
  if (opts.map) opts.project = opts.project || process.cwd();
79
- const r = await analyze({
99
+ const since = sinceTime(opts);
100
+ const run = (from) => analyze({
101
+ since: from,
80
102
  dir: opts.dir || cfg.conversationsDir || DEFAULT_DIR,
81
103
  workspaces: opts.project ? [opts.project] : [],
82
104
  customKinds: cfg.customKinds || {},
83
105
  });
106
+ const r = await run(since);
84
107
  if (opts.map) {
85
108
  const m = buildMap(opts.project, { ...cfg.map, history: r.files });
86
109
  console.log(m.block);
87
110
  return console.error(t.mapDone(m.tokens, path.join(opts.project, 'AGENTS.md')));
88
111
  }
89
- const found = findings(r, {
112
+ const find = (x) => findings(x, {
90
113
  crossSessionMin: cfg.crossSessionMin ?? DEFAULTS.crossSessionMin,
91
114
  heavyOutputTokens: cfg.heavyOutputTokens ?? DEFAULTS.heavyOutputTokens,
92
115
  longSessionTokens: cfg.longSessionTokens ?? DEFAULTS.longSessionTokens,
93
116
  });
117
+ const found = find(r);
94
118
 
95
119
  if (opts.text) return process.stdout.write(report.text(r, found, { scope: opts.project, paths: opts.paths }));
96
120
 
97
121
  const stamp = new Date().toISOString().slice(0, 16).replace(/[-:T]/g, '');
98
122
  const out = opts.out || path.join(os.tmpdir(), 'agy-insights', `${t.reportName}-${stamp}.html`);
99
123
  fs.mkdirSync(path.dirname(out), { recursive: true });
100
- fs.writeFileSync(out, report.html(r, found, { scope: opts.project }));
124
+ // Whole history by default, plus the presets the page can switch to. With --since/--days, just that period.
125
+ const views = [{ key: 'all', label: since ? '' : t.report.rangeAll, r, found }];
126
+ if (!since && r.conversations) {
127
+ for (const days of PRESET_DAYS) {
128
+ const x = await run(Date.now() - days * 24 * 60 * 60 * 1000);
129
+ views.push({ key: `d${days}`, label: t.report.rangeDays(days), r: x, found: find(x) });
130
+ }
131
+ }
132
+ fs.writeFileSync(out, report.htmlViews(views, { scope: opts.project }));
101
133
  console.log(t.done(r.conversations, found.length, out));
102
134
  if (opts.open) openInBrowser(out);
103
135
  }
@@ -34,9 +34,13 @@ 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`: configuration of each model call, including the model name.
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.
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.
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.
43
+
40
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.
41
45
 
42
46
  ### SQLite
package/lib/history.js CHANGED
@@ -10,7 +10,23 @@ const DEFAULT_DIR = path.join(os.homedir(), '.gemini', 'antigravity-cli', 'conve
10
10
  const BYTES_PER_TOKEN = 4;
11
11
  const TOOL_RE = /^[a-z][a-z0-9_]{2,40}$/;
12
12
  const FILE_KEYS = ['AbsolutePath', 'TargetFile', 'FilePath', 'File', 'Path'];
13
- const MODEL_RE = /\b(gemini-[\w.-]+|Gemini \d[\w. ]*\((?:Low|Medium|High)\)|Claude \w+ [\d.]+ \(\w+\))/g;
13
+ // Model of a call, as agy names it in gen_metadata: "Gemini 3.8 Flash (High)" (name and effort level), or
14
+ // the id "gemini-3.8-flash-high" when the display name is missing.
15
+ // Whole strings only, so that prose in the system prompt that mentions a model is not taken for one.
16
+ const MODEL_NAME_RE = /^((?:Gemini|Claude|GPT)[\w .-]*?) \(([A-Za-z][A-Za-z ]{1,15})\)$/;
17
+ const MODEL_ID_RE = /^((?:gemini|claude|gpt)-[\w.-]*?)-(minimal|low|medium|high|xhigh|max)$/;
18
+
19
+ function modelOf(strs) {
20
+ for (const s of strs) {
21
+ const m = s.length < 200 && MODEL_NAME_RE.exec(s);
22
+ if (m) return { model: m[1].trim(), effort: m[2].trim() };
23
+ }
24
+ for (const s of strs) {
25
+ const m = s.length < 200 && MODEL_ID_RE.exec(s);
26
+ if (m) return { model: m[1], effort: m[2] };
27
+ }
28
+ return { model: null, effort: null };
29
+ }
14
30
 
15
31
  // --- SQLite access: node:sqlite when the runtime has it (Node >= 22.13), sql.js (WASM) otherwise. ---
16
32
 
@@ -154,6 +170,9 @@ function locate(file, workspaces, depth = 3) {
154
170
 
155
171
  // --- Real usage ---
156
172
 
173
+ // Local calendar day of a time, as YYYY-MM-DD.
174
+ const dayKey = (ms) => { const d = new Date(ms); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; };
175
+
157
176
  // What the model was actually billed for in a conversation, from the transcript agy keeps next to the
158
177
  // history (<conversations>/../brain/<id>/.system_generated/logs/transcript.jsonl): one line per step,
159
178
  // and model calls carry input, cache-read and output tokens. Subagents have their own conversation and
@@ -162,16 +181,28 @@ function realUsage(dir, id) {
162
181
  const file = path.join(dir, '..', 'brain', id, '.system_generated', 'logs', 'transcript.jsonl');
163
182
  let text;
164
183
  try { text = fs.readFileSync(file, 'utf8'); } catch { return null; }
165
- const u = { calls: 0, input: 0, cache: 0, output: 0, errors: 0 };
184
+ // `days`: the same figures per local day of each model call (calls without a time go under null).
185
+ // `perCall`: each call's figures, in order (they match gen_metadata's rows one to one).
186
+ const u = { calls: 0, input: 0, cache: 0, output: 0, errors: 0, start: null, end: null, days: new Map(), perCall: [] };
166
187
  for (const line of text.split('\n')) {
167
188
  let e;
168
189
  try { e = JSON.parse(line); } catch { continue; }
190
+ const at = Date.parse(e.created_at);
191
+ if (at) { u.start = Math.min(u.start ?? at, at); u.end = Math.max(u.end ?? at, at); }
169
192
  if (e.status === 'ERROR') u.errors++;
170
193
  if (e.type !== 'PLANNER_RESPONSE') continue;
171
194
  u.calls++;
172
195
  u.input += Number(e.input_tokens) || 0;
173
196
  u.cache += Number(e.cache_read_tokens) || 0;
174
197
  u.output += Number(e.output_tokens) || 0;
198
+ u.perCall.push({ input: Number(e.input_tokens) || 0, cache: Number(e.cache_read_tokens) || 0, output: Number(e.output_tokens) || 0 });
199
+ const k = at ? dayKey(at) : null;
200
+ const d = u.days.get(k) || { calls: 0, input: 0, cache: 0, output: 0 };
201
+ d.calls++;
202
+ d.input += Number(e.input_tokens) || 0;
203
+ d.cache += Number(e.cache_read_tokens) || 0;
204
+ d.output += Number(e.output_tokens) || 0;
205
+ u.days.set(k, d);
175
206
  }
176
207
  return u;
177
208
  }
@@ -194,23 +225,34 @@ class Counter {
194
225
  * @param {string} [opts.dir] conversations directory
195
226
  * @param {string[]} [opts.workspaces] only count conversations that touched these folders (all if empty)
196
227
  * @param {Object<string,string>} [opts.customKinds] regex -> label, checked before the built-in types
228
+ * @param {number} [opts.since] only conversations whose last activity is at or after this time (ms)
197
229
  */
198
- async function analyze({ dir = DEFAULT_DIR, workspaces = [], customKinds = {} } = {}) {
230
+ async function analyze({ dir = DEFAULT_DIR, workspaces = [], customKinds = {}, since = null } = {}) {
199
231
  const custom = Object.entries(customKinds).map(([re, label]) => [new RegExp(re, 'i'), label]);
200
232
  const classify = (p) => custom.find(([re]) => re.test(p))?.[1] || kind(p);
201
233
  const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'agy-insights-'));
202
234
  const r = {
203
235
  dir, backend: nodeSqlite ? 'node:sqlite' : 'sql.js', conversations: 0, unreadable: 0, steps: 0, tokens: 0,
236
+ since, period: null, olderSkipped: 0,
204
237
  toolCallsFound: 0, reads: 0, rangedReads: 0, linesRead: 0, rereads: 0, rereadTokens: 0, sessions: [],
205
238
  usage: { conversations: 0, calls: 0, input: 0, cache: 0, output: 0, errors: 0 },
206
239
  };
207
240
  const tools = new Counter(), kinds = new Counter(), folders = new Counter(), files = new Counter();
208
- const commands = new Counter(), models = new Counter(), projects = new Counter();
241
+ const commands = new Counter(), projects = new Counter();
242
+ const models = new Map(); // "model|effort" -> calls and real tokens
243
+ const daily = new Map(); // YYYY-MM-DD -> real usage that day
244
+ const firstDay = since ? dayKey(since) : '';
209
245
  const wsKeys = workspaces.map(key);
210
246
 
211
247
  try {
212
248
  const dbs = fs.existsSync(dir) ? fs.readdirSync(dir).filter((f) => /^[0-9a-f-]{36}\.db$/i.test(f)) : [];
213
249
  for (const f of dbs) {
250
+ // When it happened: the transcript's step times, else the database's last write.
251
+ const id = f.replace(/\.db$/, '');
252
+ const usage = realUsage(dir, id);
253
+ const end = usage?.end ?? fs.statSync(path.join(dir, f)).mtimeMs;
254
+ const start = usage?.start ?? end;
255
+ if (since && end < since) { r.olderSkipped++; continue; }
214
256
  let db;
215
257
  try { db = await openDb(path.join(dir, f), tmp); } catch { r.unreadable++; continue; }
216
258
  try {
@@ -223,6 +265,7 @@ async function analyze({ dir = DEFAULT_DIR, workspaces = [], customKinds = {} }
223
265
  }
224
266
 
225
267
  r.conversations++;
268
+ r.period = { from: Math.min(r.period?.from ?? start, start), to: Math.max(r.period?.to ?? end, end) };
226
269
  let convBytes = 0;
227
270
  const seenRanges = new Set(), seenFiles = new Set(), seenFolders = new Set(), seenKinds = new Set(), seenProjects = new Set();
228
271
  const seenCalls = new Map(); // call id -> 1 if that call re-read a range already read, else 0
@@ -268,17 +311,38 @@ async function analyze({ dir = DEFAULT_DIR, workspaces = [], customKinds = {} }
268
311
  files.add(key(file), { ...fields, convs: once(seenFiles, key(file)) }, { abs: file, rel, root, project, kind: k });
269
312
  });
270
313
  }
271
- const id = f.replace(/\.db$/, '');
272
- const usage = realUsage(dir, id);
273
314
  if (usage) {
274
315
  r.usage.conversations++;
275
316
  for (const k of ['calls', 'input', 'cache', 'output', 'errors']) r.usage[k] += usage[k];
317
+ for (const [k, v] of usage.days) {
318
+ const day = k || dayKey(end);
319
+ if (day < firstDay) continue;
320
+ const d = daily.get(day) || { day, calls: 0, input: 0, cache: 0, output: 0 };
321
+ for (const f of ['calls', 'input', 'cache', 'output']) d[f] += v[f];
322
+ daily.set(day, d);
323
+ }
276
324
  }
277
- r.sessions.push({ id, steps: steps.length, tokens: convBytes / BYTES_PER_TOKEN, usage });
278
- r.tokens += convBytes / BYTES_PER_TOKEN;
279
- for (const [blob] of db.all('select data from gen_metadata')) {
280
- for (const s of collect(blob)) for (const m of s.matchAll(MODEL_RE)) models.add(m[1], { calls: 1 });
325
+ // One gen_metadata row per model call, in the transcript's order: the n-th row's model made the n-th call.
326
+ const callModels = db.all('select data from gen_metadata order by idx').map(([blob]) => modelOf(collect(blob)));
327
+ const n = Math.max(callModels.length, usage ? usage.perCall.length : 0);
328
+ const seenModels = new Set();
329
+ for (let i = 0; i < n; i++) {
330
+ const { model, effort } = callModels[i] || { model: null, effort: null };
331
+ const k = `${model}|${effort}`;
332
+ const e = models.get(k) || { model, effort, calls: 0, convs: 0, input: 0, cache: 0, output: 0, known: 0 };
333
+ e.calls++;
334
+ if (!seenModels.has(k)) { seenModels.add(k); e.convs++; }
335
+ const c = usage?.perCall[i];
336
+ if (c) { e.known++; e.input += c.input; e.cache += c.cache; e.output += c.output; }
337
+ models.set(k, e);
281
338
  }
339
+ // The session's model: the one that made most of its calls.
340
+ const counts = new Map();
341
+ for (const m of callModels) counts.set(`${m.model}|${m.effort}`, (counts.get(`${m.model}|${m.effort}`) || 0) + 1);
342
+ const main = [...counts].sort((a, b) => b[1] - a[1])[0];
343
+ const model = main ? callModels.find((m) => `${m.model}|${m.effort}` === main[0]) : null;
344
+ r.sessions.push({ id, start, end, steps: steps.length, tokens: convBytes / BYTES_PER_TOKEN, usage, model: model?.model ? model : null, models: counts.size });
345
+ r.tokens += convBytes / BYTES_PER_TOKEN;
282
346
  } catch { r.unreadable++; } finally { db.close(); }
283
347
  }
284
348
  } finally {
@@ -293,8 +357,10 @@ async function analyze({ dir = DEFAULT_DIR, workspaces = [], customKinds = {} }
293
357
  return {
294
358
  ...r,
295
359
  tools: tools.all(), kinds: kinds.all(), folders: folders.all(), files: files.all(), projects: projects.all(),
296
- commands: commands.all(), models: models.all(),
360
+ commands: commands.all(),
361
+ models: [...models.values()].sort((a, b) => b.input + b.output - (a.input + a.output) || b.calls - a.calls),
362
+ daily: [...daily.values()].sort((a, b) => (a.day < b.day ? -1 : 1)),
297
363
  };
298
364
  }
299
365
 
300
- module.exports = { analyze, DEFAULT_DIR, kind, NON_SOURCE, realUsage };
366
+ module.exports = { analyze, DEFAULT_DIR, kind, NON_SOURCE, realUsage, dayKey, modelOf };
package/lib/i18n.js CHANGED
@@ -17,6 +17,8 @@ Options:
17
17
  --map Print a project map (--project, or the current folder) for AGENTS.md
18
18
  --out <file> Where to save the HTML report (default: the temp folder)
19
19
  --no-open Don't open the browser
20
+ --since <YYYY-MM-DD> Only conversations active since that date
21
+ --days <n> Only conversations active in the last n days
20
22
  --lang <en|es> Language of the output (default: the system's)
21
23
  --dir <folder> Another history folder (default ${dir})
22
24
  --config <file> Configuration (default ~/.agy-insights.json if it exists)
@@ -25,11 +27,13 @@ Options:
25
27
  missingValue: (opt) => `Missing value for ${opt}`,
26
28
  unknownOption: (opt) => `Unknown option: ${opt}. See --help.`,
27
29
  badLang: (v) => `Unsupported language: ${v}. Use en or es.`,
30
+ badDate: (v) => `Invalid date: ${v}. Use YYYY-MM-DD.`,
31
+ badDays: (v) => `Invalid number of days: ${v}.`,
28
32
  configNotFound: (f) => `Configuration file not found: ${f}`,
29
33
  cannotRead: (f, msg) => `Cannot read ${f}: ${msg}`,
30
34
  mapDone: (tokens, file) => `\n~${tokens} tokens on every request. Paste it into ${file} if you like it; agy-insights does not write it.`,
31
35
  reportName: 'report',
32
- done: (convs, found, out) => `${convs} conversations analyzed, ${found} findings.\nReport: ${out}`,
36
+ done: (convs, found, out) => `${plural(convs, 'conversation', 'conversations')} analyzed, ${plural(found, 'finding', 'findings')}.\nReport: ${out}`,
33
37
 
34
38
  kinds: {
35
39
  build: 'build output (dist, bin, obj…)', logs: 'logs, maps, minified', generated: 'generated code / migrations',
@@ -78,7 +82,7 @@ Options:
78
82
  longSuggestion: 'A new conversation per task, or /compact when you change topic.',
79
83
  conversation: (id) => `conversation ${id}`,
80
84
  steps: (n) => plural(n, 'step', 'steps'),
81
- longUsage: (calls, real, cached) => ` · ${calls} model calls, ${real} real tokens (${cached} with cache)`,
85
+ longUsage: (calls, real, cached) => ` · ${plural(calls, 'model call', 'model calls')}, ${real} real tokens (${cached} with cache)`,
82
86
  },
83
87
 
84
88
  map: {
@@ -99,11 +103,20 @@ Options:
99
103
  exampleRule: 'Example rule for AGENTS.md',
100
104
  exampleHint: "Add it to the project's AGENTS.md only if this happens often: rules are sent with every request.",
101
105
  cols: ['What', '~tokens', '%', 'Times', 'Sessions'],
106
+ modelsTitle: 'Models and effort',
107
+ modelsHint: 'Model and effort level (reasoning) of each model call, as agy records them. Higher effort usually means more tokens per call.',
108
+ modelCols: ['Model', 'Effort', 'Calls', 'Tokens', '%', 'Per call', 'With cache', 'Sessions'],
109
+ unknownModel: 'unknown',
110
+ modelsText: 'Models and effort:',
111
+ modelLine: (model, effort, calls, tokens, pct, perCall) => `${model} · ${effort} ${plural(calls, 'call', 'calls')}, ${tokens} tokens (${pct}), ~${perCall} per call`,
112
+ sessionModel: 'Model',
113
+ otherModels: (n) => ` +${n} more`,
114
+ efforts: { minimal: 'Minimal', low: 'Low', medium: 'Medium', high: 'High', xhigh: 'Extra high', 'extra high': 'Extra high', max: 'Max' },
102
115
  sessionsTitle: 'Sessions (real usage)',
103
- sessionCols: ['Conversation', 'Tokens', '%', 'With cache', 'Calls', 'Errors'],
116
+ sessionCols: ['Conversation', 'Model', 'Tokens', '%', 'With cache', 'Calls', 'Errors'],
104
117
  noConversations: (scope, dir) => `No conversations${scope ? ` for <code>${scope}</code>` : ''} in <code>${dir}</code>.`,
105
118
  tileReal: 'real tokens (input + output)',
106
- tileCache: (calls) => `including cache · ${calls} calls`,
119
+ tileCache: (calls) => `including cache · ${plural(calls, 'call', 'calls')}`,
107
120
  tileContext: '~tokens in context',
108
121
  tileTop5: 'in the 5 largest sessions',
109
122
  tileReads: 'file reads',
@@ -116,18 +129,35 @@ Options:
116
129
  byKind: 'By file type', byFolder: 'By folder', files: 'Files', tools: 'Tools', commands: 'Commands',
117
130
  title: 'Context usage in Antigravity',
118
131
  project: 'Project', wholeHistory: 'Whole history',
119
- meta: (convs, date) => `${convs} conversations · generated ${date}`,
120
- footerReal: (missing) => `Real tokens: the ones Antigravity records in each conversation's log (subagents included)${missing ? `; ${missing} conversations have no log and are not counted` : ''}.`,
132
+ convs: (n) => plural(n, 'conversation', 'conversations'),
133
+ generated: (date) => `generated ${date}`,
134
+ filterLabel: 'Period',
135
+ rangeAll: 'All history',
136
+ rangeDays: (n) => `Last ${n} days`,
137
+ chartTitleDay: 'Token usage per day',
138
+ chartTitleWeek: 'Token usage per week',
139
+ chartHint: 'Real tokens (input + output) of the model calls. Hover a column for details.',
140
+ chartTable: 'View as table',
141
+ chartCols: ['Day', 'Tokens', 'With cache', 'Calls'],
142
+ chartColsWeek: ['Week of', 'Tokens', 'With cache', 'Calls'],
143
+ chartCalls: (n) => plural(n, 'call', 'calls'),
144
+ weekOf: (d) => `week of ${d}`,
145
+ withCacheShort: 'with cache',
146
+ period: (from, to) => (from === to ? `on ${from}` : `from ${from} to ${to}`),
147
+ since: (date) => `active since ${date}`,
148
+ olderSkipped: (n) => `${plural(n, 'older conversation', 'older conversations')} left out by the date filter`,
149
+ periodLine: (period, since) => `Period: ${period}${since ? ` (filter: ${since})` : ''}`,
150
+ 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` : ''}.`,
121
151
  footerCache: 'It is unknown whether the quota counts cache the same as new input, so both figures are shown.',
122
152
  footerEstimate: '~tokens = stored size of each step / 4: useful to compare files and folders with each other.',
123
153
  footerPrivacy: 'The history was read from a copy and was not modified. This page contains paths from your projects: review it before sharing.',
124
154
  // Text summary
125
155
  history: 'History', projectLower: 'project',
126
- summary: (convs, steps, tokens) => `${convs} conversations, ${steps} steps, ~${tokens} tokens in context (estimated)`,
127
- realUsage: (real, input, output, cached, calls) => `Real usage: ${real} tokens (input ${input} + output ${output}), ${cached} including cache, in ${calls} model calls`,
128
- noLog: (n) => ` (${n} conversations without a log)`,
129
- topSessions: (n) => ` The ${n} sessions that use the most:`,
130
- sessionLine: (id, calls, errors, cached) => `conversation ${id} ${calls} calls${errors ? `, ${errors} errors` : ''}, ${cached} with cache`,
156
+ summary: (convs, steps, tokens) => `${plural(convs, 'conversation', 'conversations')}, ${plural(steps, 'step', 'steps')}, ~${tokens} tokens in context (estimated)`,
157
+ realUsage: (real, input, output, cached, calls) => `Real usage: ${real} tokens (input ${input} + output ${output}), ${cached} including cache, in ${plural(calls, 'model call', 'model calls')}`,
158
+ noLog: (n) => ` (${plural(n, 'conversation', 'conversations')} without a log)`,
159
+ topSessions: (n) => (n === 1 ? ' The session that uses the most:' : ` The ${n} sessions that use the most:`),
160
+ sessionLine: (id, calls, errors, cached, model) => `conversation ${id}${model ? ` (${model})` : ''} ${plural(calls, 'call', 'calls')}${errors ? `, ${plural(errors, 'error', 'errors')}` : ''}, ${cached} with cache`,
131
161
  top5: (pct, reads, rereads, rpct) => `The 5 largest sessions account for ${pct}. Reads: ${reads}, re-reads ${rereads} (${rpct})`,
132
162
  pathHidden: '(path hidden, use --paths)',
133
163
  byKindText: 'By file type:',
@@ -148,6 +178,8 @@ Opciones:
148
178
  --map Imprime un mapa del proyecto (--project, o la carpeta actual) para AGENTS.md
149
179
  --out <fichero> Dónde guardar el informe HTML (por defecto, en la carpeta temporal)
150
180
  --no-open No abrir el navegador
181
+ --since <AAAA-MM-DD> Solo conversaciones con actividad desde esa fecha
182
+ --days <n> Solo conversaciones con actividad en los últimos n días
151
183
  --lang <en|es> Idioma de la salida (por defecto, el del sistema)
152
184
  --dir <carpeta> Otra carpeta de historial (por defecto ${dir})
153
185
  --config <fichero> Configuración (por defecto ~/.agy-insights.json si existe)
@@ -156,11 +188,13 @@ Opciones:
156
188
  missingValue: (opt) => `Falta el valor de ${opt}`,
157
189
  unknownOption: (opt) => `Opción desconocida: ${opt}. Usa --help.`,
158
190
  badLang: (v) => `Idioma no soportado: ${v}. Usa en o es.`,
191
+ badDate: (v) => `Fecha no válida: ${v}. Usa AAAA-MM-DD.`,
192
+ badDays: (v) => `Número de días no válido: ${v}.`,
159
193
  configNotFound: (f) => `No existe el fichero de configuración ${f}`,
160
194
  cannotRead: (f, msg) => `No se puede leer ${f}: ${msg}`,
161
195
  mapDone: (tokens, file) => `\n~${tokens} tokens en cada petición. Pégalo en ${file} si te convence; agy-insights no lo escribe.`,
162
196
  reportName: 'informe',
163
- done: (convs, found, out) => `${convs} conversaciones analizadas, ${found} hallazgos.\nInforme: ${out}`,
197
+ done: (convs, found, out) => `${plural(convs, 'conversación analizada', 'conversaciones analizadas')}, ${plural(found, 'hallazgo', 'hallazgos')}.\nInforme: ${out}`,
164
198
 
165
199
  kinds: {
166
200
  build: 'build output (dist, bin, obj…)', logs: 'logs, maps, minificados', generated: 'código generado / migraciones',
@@ -209,7 +243,7 @@ Opciones:
209
243
  longSuggestion: 'Una conversación nueva por tarea, o /compact cuando cambias de tema.',
210
244
  conversation: (id) => `conversación ${id}`,
211
245
  steps: (n) => plural(n, 'paso', 'pasos'),
212
- longUsage: (calls, real, cached) => ` · ${calls} llamadas al modelo, ${real} tokens reales (${cached} con caché)`,
246
+ longUsage: (calls, real, cached) => ` · ${plural(calls, 'llamada', 'llamadas')} al modelo, ${real} tokens reales (${cached} con caché)`,
213
247
  },
214
248
 
215
249
  map: {
@@ -230,11 +264,20 @@ Opciones:
230
264
  exampleRule: 'Regla de ejemplo para AGENTS.md',
231
265
  exampleHint: 'Ponla en el AGENTS.md del proyecto solo si te pasa a menudo: las reglas se envían en cada petición.',
232
266
  cols: ['Qué', '~tokens', '%', 'Veces', 'Sesiones'],
267
+ modelsTitle: 'Modelos y esfuerzo',
268
+ modelsHint: 'Modelo y nivel de esfuerzo (razonamiento) de cada llamada al modelo, tal como los anota agy. Más esfuerzo suele suponer más tokens por llamada.',
269
+ modelCols: ['Modelo', 'Esfuerzo', 'Llamadas', 'Tokens', '%', 'Por llamada', 'Con caché', 'Sesiones'],
270
+ unknownModel: 'desconocido',
271
+ modelsText: 'Modelos y esfuerzo:',
272
+ modelLine: (model, effort, calls, tokens, pct, perCall) => `${model} · ${effort} ${plural(calls, 'llamada', 'llamadas')}, ${tokens} tokens (${pct}), ~${perCall} por llamada`,
273
+ sessionModel: 'Modelo',
274
+ otherModels: (n) => ` +${n} más`,
275
+ efforts: { minimal: 'Mínimo', low: 'Bajo', medium: 'Medio', high: 'Alto', xhigh: 'Muy alto', 'extra high': 'Muy alto', max: 'Máximo' },
233
276
  sessionsTitle: 'Sesiones (consumo real)',
234
- sessionCols: ['Conversación', 'Tokens', '%', 'Con caché', 'Llamadas', 'Errores'],
277
+ sessionCols: ['Conversación', 'Modelo', 'Tokens', '%', 'Con caché', 'Llamadas', 'Errores'],
235
278
  noConversations: (scope, dir) => `No hay conversaciones${scope ? ` de <code>${scope}</code>` : ''} en <code>${dir}</code>.`,
236
279
  tileReal: 'tokens reales (entrada + salida)',
237
- tileCache: (calls) => `contando la caché · ${calls} llamadas`,
280
+ tileCache: (calls) => `contando la caché · ${plural(calls, 'llamada', 'llamadas')}`,
238
281
  tileContext: '~tokens en contexto',
239
282
  tileTop5: 'en las 5 sesiones más grandes',
240
283
  tileReads: 'lecturas de ficheros',
@@ -247,17 +290,34 @@ Opciones:
247
290
  byKind: 'Por tipo de fichero', byFolder: 'Por carpeta', files: 'Ficheros', tools: 'Herramientas', commands: 'Comandos',
248
291
  title: 'Uso de contexto en Antigravity',
249
292
  project: 'Proyecto', wholeHistory: 'Todo el historial',
250
- meta: (convs, date) => `${convs} conversaciones · generado ${date}`,
251
- footerReal: (missing) => `Tokens reales: los que Antigravity anota en el registro de cada conversación (incluidos subagentes)${missing ? `; ${missing} conversaciones no tienen registro y no cuentan` : ''}.`,
293
+ convs: (n) => plural(n, 'conversación', 'conversaciones'),
294
+ generated: (date) => `generado ${date}`,
295
+ filterLabel: 'Periodo',
296
+ rangeAll: 'Todo el historial',
297
+ rangeDays: (n) => `Últimos ${n} días`,
298
+ chartTitleDay: 'Consumo de tokens por día',
299
+ chartTitleWeek: 'Consumo de tokens por semana',
300
+ chartHint: 'Tokens reales (entrada + salida) de las llamadas al modelo. Pasa el ratón por una columna para ver el detalle.',
301
+ chartTable: 'Ver como tabla',
302
+ chartCols: ['Día', 'Tokens', 'Con caché', 'Llamadas'],
303
+ chartColsWeek: ['Semana del', 'Tokens', 'Con caché', 'Llamadas'],
304
+ chartCalls: (n) => plural(n, 'llamada', 'llamadas'),
305
+ weekOf: (d) => `semana del ${d}`,
306
+ withCacheShort: 'con caché',
307
+ period: (from, to) => (from === to ? `el ${from}` : `del ${from} al ${to}`),
308
+ since: (date) => `con actividad desde el ${date}`,
309
+ olderSkipped: (n) => `${plural(n, 'conversación anterior queda fuera', 'conversaciones anteriores quedan fuera')} por el filtro de fechas`,
310
+ periodLine: (period, since) => `Periodo: ${period}${since ? ` (filtro: ${since})` : ''}`,
311
+ 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`}` : ''}.`,
252
312
  footerCache: 'No se sabe si la cuota cuenta la caché igual que la entrada nueva: por eso se muestran las dos cifras.',
253
313
  footerEstimate: '~tokens = tamaño guardado de cada paso / 4: sirve para comparar ficheros y carpetas entre sí.',
254
314
  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.',
255
315
  history: 'Historial', projectLower: 'proyecto',
256
- summary: (convs, steps, tokens) => `${convs} conversaciones, ${steps} pasos, ~${tokens} tokens en contexto (estimado)`,
257
- realUsage: (real, input, output, cached, calls) => `Consumo real: ${real} tokens (entrada ${input} + salida ${output}), ${cached} contando la caché, en ${calls} llamadas al modelo`,
258
- noLog: (n) => ` (${n} conversaciones sin registro)`,
259
- topSessions: (n) => ` Las ${n} sesiones que más gastan:`,
260
- sessionLine: (id, calls, errors, cached) => `conversación ${id} ${calls} llamadas${errors ? `, ${errors} errores` : ''}, ${cached} con caché`,
316
+ summary: (convs, steps, tokens) => `${plural(convs, 'conversación', 'conversaciones')}, ${plural(steps, 'paso', 'pasos')}, ~${tokens} tokens en contexto (estimado)`,
317
+ 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`,
318
+ noLog: (n) => ` (${plural(n, 'conversación', 'conversaciones')} sin registro)`,
319
+ topSessions: (n) => (n === 1 ? ' La sesión que más gasta:' : ` Las ${n} sesiones que más gastan:`),
320
+ sessionLine: (id, calls, errors, cached, model) => `conversación ${id}${model ? ` (${model})` : ''} ${plural(calls, 'llamada', 'llamadas')}${errors ? `, ${plural(errors, 'error', 'errores')}` : ''}, ${cached} con caché`,
261
321
  top5: (pct, reads, rereads, rpct) => `Las 5 sesiones más grandes suman el ${pct}. Lecturas: ${reads}, relecturas ${rereads} (${rpct})`,
262
322
  pathHidden: '(ruta oculta, usa --paths)',
263
323
  byKindText: 'Por tipo de fichero:',
@@ -283,6 +343,7 @@ function setLang(lang) {
283
343
  const t = new Proxy({}, { get: (_, k) => LANGS[current][k] });
284
344
  const lang = () => current;
285
345
  const num = (n) => Math.round(n).toLocaleString(t.locale);
346
+ const date = (ms) => new Date(ms).toLocaleDateString(t.locale, { day: 'numeric', month: 'short', year: 'numeric' });
286
347
 
287
348
  // Built-in kinds are stable ids (see history.js kind()); custom kinds from the config are shown as written.
288
349
  function kindLabel(k) {
@@ -290,4 +351,7 @@ function kindLabel(k) {
290
351
  return t.kinds[k] || k;
291
352
  }
292
353
 
293
- module.exports = { t, setLang, lang, detect, num, kindLabel, LANGS };
354
+ // "High" / "high" → "High" or "Alto"; levels agy may add later are shown as written.
355
+ const effortLabel = (e) => (e ? t.report.efforts[e.toLowerCase()] || e : t.report.unknownModel);
356
+
357
+ module.exports = { t, setLang, lang, detect, num, date, kindLabel, effortLabel, LANGS };
package/lib/report.js CHANGED
@@ -1,11 +1,15 @@
1
1
  // Renders the analysis as a self-contained HTML page (opened in the browser) or as plain text.
2
- // The page is static: its only interaction is copying text, so it cannot change anything on disk.
2
+ // The page is static: its only interactions are switching the period, the chart tooltip and copying text,
3
+ // so it cannot change anything on disk.
3
4
 
4
- const { t, lang, num, kindLabel } = require('./i18n');
5
+ const { t, lang, num, date, kindLabel, effortLabel } = require('./i18n');
6
+ const { dayKey } = require('./history');
5
7
 
6
8
  const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
7
9
  const fmt = num;
8
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))) : '');
9
13
  const label = (x) => (x.project ? `${x.project} › ${x.rel}` : x.abs || x.key);
10
14
 
11
15
  // Real usage, from agy's transcripts: what counts against the quota, unlike the size estimate.
@@ -55,55 +59,176 @@ function sessionsTable(r) {
55
59
  const rows = r.sessions.filter((s) => s.usage).slice(0, 15);
56
60
  if (!rows.length) return '';
57
61
  const max = Math.max(...rows.map((s) => billed(s.usage)), 1);
58
- return `<h3>${t.report.sessionsTitle}</h3><div class="scroll"><table><thead><tr>${t.report.sessionCols.map((c, i) => `<th${i ? ' class="num"' : ''}>${c}</th>`).join('')}</tr></thead><tbody>
59
- ${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>
60
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>
61
65
  <td class="num">${s.usage.calls}</td><td class="num">${s.usage.errors || ''}</td></tr>`).join('')}
62
66
  </tbody></table></div>`;
63
67
  }
64
68
 
65
- function html(r, found, { scope }) {
69
+ // "Gemini 3.8 Flash · High", or "Gemini 3.8 Flash · High +1 more" when the session used several.
70
+ const modelName = (m) => (m.model || t.report.unknownModel);
71
+ const sessionModel = (s) => (s.model ? `${modelName(s.model)} · ${effortLabel(s.model.effort)}${s.models > 1 ? t.report.otherModels(s.models - 1) : ''}` : '');
72
+
73
+ // Calls and real tokens per model and effort level.
74
+ function modelsTable(r) {
75
+ const rows = r.models.filter((m) => m.model || m.calls);
76
+ if (!rows.length || (rows.length === 1 && !rows[0].model)) return '';
77
+ const total = rows.reduce((s, m) => s + m.input + m.output, 0);
78
+ const max = Math.max(...rows.map((m) => m.input + m.output), 1);
79
+ return `<section class="models"><h2>${t.report.modelsTitle}</h2><p class="hint">${t.report.modelsHint}</p>
80
+ <div class="scroll"><table><thead><tr>${t.report.modelCols.map((c, i) => `<th${i > 1 ? ' class="num"' : ''}>${c}</th>`).join('')}</tr></thead><tbody>
81
+ ${rows.map((m) => { const tok = m.input + m.output; return `<tr><td><div class="bar" style="--w:${((100 * tok) / max).toFixed(1)}%"></div><span>${esc(modelName(m))}</span></td>
82
+ <td>${esc(effortLabel(m.effort))}</td><td class="num">${fmt(m.calls)}</td><td class="num">${m.known ? fmt(tok) : '–'}</td><td class="num">${m.known ? pct(tok, total) : '–'}</td>
83
+ <td class="num">${m.known ? fmt(tok / m.known) : '–'}</td><td class="num">${m.known ? fmt(tok + m.cache) : '–'}</td><td class="num">${m.convs}</td></tr>`; }).join('')}
84
+ </tbody></table></div></section>`;
85
+ }
86
+
87
+ // --- Usage chart ---
88
+
89
+ const DAY = 24 * 60 * 60 * 1000;
90
+ const parseDay = (k) => { const [y, m, d] = k.split('-').map(Number); return new Date(y, m - 1, d); };
91
+ const shortDate = (d, year) => d.toLocaleDateString(t.locale, { day: 'numeric', month: 'short', ...(year ? { year: 'numeric' } : {}) });
92
+ const compact = (n) => new Intl.NumberFormat(t.locale, { notation: 'compact', maximumFractionDigits: 1 }).format(n);
93
+ // 1, 2, 2.5, 5 × 10^k: a round top for the y axis.
94
+ const niceMax = (v) => { const p = 10 ** Math.floor(Math.log10(v || 1)); return [1, 2, 2.5, 5, 10].map((m) => m * p).find((x) => x >= v); };
95
+
96
+ // Real usage per day (per week past ~4 months), continuous: days without calls are zero-height columns.
97
+ function buckets(r) {
98
+ if (!r.daily.length) return null;
99
+ const first = r.since ? parseDay(dayKey(r.since)) : parseDay(r.daily[0].day);
100
+ const last = r.since ? parseDay(dayKey(Date.now())) : parseDay(r.daily[r.daily.length - 1].day);
101
+ const weekly = (last - first) / DAY > 120;
102
+ const startOf = (d) => (weekly ? new Date(d.getFullYear(), d.getMonth(), d.getDate() - ((d.getDay() + 6) % 7)) : d);
103
+ const byKey = new Map();
104
+ for (let d = startOf(first); d <= last; d = new Date(d.getFullYear(), d.getMonth(), d.getDate() + (weekly ? 7 : 1))) {
105
+ byKey.set(dayKey(d.getTime()), { date: d, billed: 0, cache: 0, calls: 0 });
106
+ }
107
+ for (const x of r.daily) {
108
+ const b = byKey.get(dayKey(startOf(parseDay(x.day)).getTime()));
109
+ if (!b) continue;
110
+ b.billed += x.input + x.output;
111
+ b.cache += x.cache;
112
+ b.calls += x.calls;
113
+ }
114
+ return { weekly, rows: [...byKey.values()] };
115
+ }
116
+
117
+ function chart(r) {
118
+ const b = buckets(r);
119
+ if (!b) return '';
120
+ const W = 900, H = 240, m = { l: 56, r: 8, t: 12, b: 28 };
121
+ const pw = W - m.l - m.r, ph = H - m.t - m.b;
122
+ const top = niceMax(Math.max(...b.rows.map((x) => x.billed)));
123
+ const band = pw / b.rows.length;
124
+ const bw = Math.max(1, Math.min(24, band - 2));
125
+ const y = (v) => m.t + ph - (v / top) * ph;
126
+ const when = (x) => (b.weekly ? t.report.weekOf(shortDate(x.date, true)) : shortDate(x.date, true));
127
+
128
+ const grid = [0, top / 2, top].map((v) => `<line x1="${m.l}" x2="${W - m.r}" y1="${y(v)}" y2="${y(v)}" class="grid"/>
129
+ <text x="${m.l - 8}" y="${y(v) + 4}" text-anchor="end" class="axis">${esc(compact(v))}</text>`).join('');
130
+ // Rounded data end (4px), square at the baseline.
131
+ const bars = b.rows.map((x, i) => {
132
+ const h = (x.billed / top) * ph;
133
+ if (h <= 0) return '';
134
+ const x0 = m.l + i * band + (band - bw) / 2, y0 = m.t + ph - h, rr = Math.min(4, h, bw / 2);
135
+ return `<path class="col" d="M${x0},${m.t + ph}V${y0 + rr}Q${x0},${y0} ${x0 + rr},${y0}H${x0 + bw - rr}Q${x0 + bw},${y0} ${x0 + bw},${y0 + rr}V${m.t + ph}Z"/>`;
136
+ }).join('');
137
+ // Hit targets: the whole band, so a 2px column is as easy to hover as a 24px one.
138
+ const hits = b.rows.map((x, i) => `<rect class="hit" x="${m.l + i * band}" y="${m.t}" width="${band}" height="${ph}"
139
+ data-when="${esc(when(x))}" data-v="${esc(fmt(x.billed))}" data-c="${esc(fmt(x.billed + x.cache))}" data-n="${esc(t.report.chartCalls(x.calls))}"/>`).join('');
140
+ const every = Math.max(1, Math.ceil(b.rows.length / 6));
141
+ // A date every few columns, plus the last one when it isn't crowded by the previous label.
142
+ const last = b.rows.length - 1;
143
+ const ticks = b.rows.map((x, i) => (i % every === 0 || (i === last && last % every >= every / 2)) && `<text x="${i === 0 && band < 40 ? m.l : i === last && band < 40 ? W - m.r : m.l + i * band + band / 2}" y="${H - 8}" text-anchor="${i === 0 && band < 40 ? 'start' : i === last && band < 40 ? 'end' : 'middle'}" class="axis">${esc(shortDate(x.date))}</text>`).filter(Boolean).join('');
144
+
145
+ const rows = b.rows.filter((x) => x.calls);
146
+ return `<section class="chart">
147
+ <h2>${b.weekly ? t.report.chartTitleWeek : t.report.chartTitleDay}</h2>
148
+ <p class="hint">${t.report.chartHint}</p>
149
+ <div class="plot"><svg viewBox="0 0 ${W} ${H}" role="img" aria-label="${esc(b.weekly ? t.report.chartTitleWeek : t.report.chartTitleDay)}">${grid}${bars}${ticks}${hits}</svg></div>
150
+ <details><summary>${t.report.chartTable}</summary><div class="scroll"><table><thead><tr>${(b.weekly ? t.report.chartColsWeek : t.report.chartCols).map((c, i) => `<th${i ? ' class="num"' : ''}>${c}</th>`).join('')}</tr></thead><tbody>
151
+ ${rows.map((x) => `<tr><td>${esc(shortDate(x.date, true))}</td><td class="num">${fmt(x.billed)}</td><td class="num">${fmt(x.billed + x.cache)}</td><td class="num">${x.calls}</td></tr>`).join('')}
152
+ </tbody></table></div></details>
153
+ </section>`;
154
+ }
155
+
156
+ // --- Page ---
157
+
158
+ function body(r, found, scope) {
66
159
  const top5 = r.sessions.slice(0, 5).reduce((s, x) => s + x.tokens, 0);
67
160
  const u = r.usage;
68
- const body = !r.conversations
69
- ? `<p>${t.report.noConversations(scope && esc(scope), esc(r.dir))}</p>`
70
- : `<div class="tiles">
71
- ${u.conversations ? `<div><b>${fmt(billed(u))}</b><span>${t.report.tileReal}</span></div>
72
- <div><b>${fmt(withCache(u))}</b><span>${t.report.tileCache(u.calls)}</span></div>` : ''}
73
- <div><b>${fmt(r.tokens)}</b><span>${t.report.tileContext}</span></div>
74
- <div><b>${pct(top5, r.tokens)}</b><span>${t.report.tileTop5}</span></div>
75
- <div><b>${r.reads}</b><span>${t.report.tileReads}</span></div>
76
- <div><b>${pct(r.rereads, r.reads)}</b><span>${t.report.tileRereads}</span></div>
77
- </div>
78
- <h2>${t.report.happening}</h2>
79
- <p class="hint">${t.report.happeningHint}</p>
80
- ${found.length ? found.map((f) => finding(f, r.tokens)).join('') : `<p>${t.report.nothing}</p>`}
81
- <details class="detail-all"><summary><h2>${t.report.details}</h2></summary>
82
- ${sessionsTable(r)}
83
- ${table(t.report.byProject, r.projects, r.tokens, (x) => x.project || t.report.outsideGit)}
84
- ${table(t.report.byKind, r.kinds, r.tokens, (x) => kindLabel(x.key))}
85
- ${table(t.report.byFolder, r.folders, r.tokens)}
86
- ${table(t.report.files, r.files, r.tokens)}
87
- ${table(t.report.tools, r.tools, r.tokens, (x) => x.key)}
88
- ${table(t.report.commands, r.commands, r.tokens, (x) => x.key)}
89
- </details>`;
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('');
90
201
 
91
202
  return `<!doctype html><html lang="${lang()}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
92
203
  <title>agy-insights</title>
93
204
  <style>
94
205
  :root { --bg: #f7f8f7; --surface: #fff; --fg: #1d2421; --muted: #5d6963; --line: #dde3df; --accent: #1f7a5a; --accent-soft: #e3f1ea;
95
- --warn: #b06a00; --code: #eef1ef; --bar: #1f7a5a; color-scheme: light; }
206
+ --warn: #b06a00; --code: #eef1ef; --bar: #1f7a5a; --series: #13805a; color-scheme: light; }
96
207
  @media (prefers-color-scheme: dark) { :root { --bg: #141917; --surface: #1b211e; --fg: #e3e9e5; --muted: #9aa8a0; --line: #2c3530;
97
- --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; } }
98
209
  body { margin: 0; background: var(--bg); color: var(--fg); font: 15px/1.55 system-ui, -apple-system, "Segoe UI", sans-serif; }
99
210
  main { max-width: 980px; margin: 0 auto; padding: 32px 16px 48px; }
100
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; }
101
212
  .meta, .hint, .detail, .tok, .where { color: var(--muted); } .tok { font-weight: normal; font-size: 14px; }
102
213
  code, pre { font-family: ui-monospace, "Cascadia Mono", Consolas, monospace; font-size: 13px; }
103
214
  code { background: var(--code); padding: 1px 5px; border-radius: 4px; overflow-wrap: anywhere; }
104
- .tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 12px; margin: 20px 0; }
215
+ .filter { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin: 20px 0 4px; }
216
+ .filter span { color: var(--muted); font-size: 14px; margin-right: 4px; }
217
+ .filter button { font: 14px system-ui, sans-serif; color: var(--fg); background: var(--surface); border: 1px solid var(--line); border-radius: 999px; padding: 4px 12px; cursor: pointer; }
218
+ .filter button:hover { background: var(--accent-soft); }
219
+ .filter button[aria-pressed="true"] { background: var(--accent); border-color: var(--accent); color: var(--surface); font-weight: 600; }
220
+ .filter button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
221
+ .tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 12px; margin: 16px 0 20px; }
105
222
  .tiles div { background: var(--surface); border: 1px solid var(--line); border-radius: 8px; padding: 12px 14px; }
106
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); }
107
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; }
108
233
  .finding.warn { border-left-color: var(--warn); } .finding p { margin: 4px 0; }
109
234
  .items { list-style: none; margin: 10px 0 0; padding: 0; }
@@ -128,15 +253,23 @@ function html(r, found, { scope }) {
128
253
  footer { margin-top: 32px; font-size: 13px; color: var(--muted); }
129
254
  </style></head><body><main>
130
255
  <h1>${t.report.title}</h1>
131
- <p class="meta">${scope ? `${t.report.project} <code>${esc(scope)}</code>` : t.report.wholeHistory} · ${t.report.meta(r.conversations, esc(new Date().toLocaleString(t.locale)))}</p>
132
- ${body}
133
- <footer>${esc(t.report.footerReal(r.conversations - u.conversations))}
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))}
134
260
  ${esc(t.report.footerCache)}
135
261
  ${esc(t.report.footerEstimate)}
136
262
  ${esc(t.report.footerPrivacy)}</footer>
137
263
  </main>
264
+ <div class="tip" hidden><b></b><span></span><span></span></div>
138
265
  <script>
139
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
+ }
140
273
  const b = e.target.closest('.copy'); if (!b) return;
141
274
  const pre = b.parentElement.querySelector('pre');
142
275
  const ok = () => { b.textContent = ${JSON.stringify(t.report.copied)}; setTimeout(() => (b.textContent = ${JSON.stringify(t.report.copy)}), 1500); };
@@ -145,15 +278,36 @@ function html(r, found, { scope }) {
145
278
  const sel = getSelection(); sel.removeAllRanges(); sel.addRange(range);
146
279
  });
147
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
+ });
148
295
  </script></body></html>`;
149
296
  }
150
297
 
298
+ function html(r, found, opts) {
299
+ return htmlViews([{ key: 'all', label: '', r, found }], opts);
300
+ }
301
+
302
+
151
303
  // --- Text ---
152
304
 
153
305
  function text(r, found, { scope, paths }) {
154
306
  const top5 = r.sessions.slice(0, 5).reduce((s, x) => s + x.tokens, 0);
155
307
  const out = [`\n${t.report.history}: ${r.dir} (${r.backend})${scope ? ` · ${t.report.projectLower} ${scope}` : ''}`,
156
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)})`);
157
311
  if (!r.conversations) return out.join('\n') + '\n';
158
312
  const u = r.usage;
159
313
  if (u.conversations) {
@@ -162,10 +316,19 @@ function text(r, found, { scope, paths }) {
162
316
  const top = r.sessions.filter((s) => s.usage).slice(0, 5);
163
317
  out.push(t.report.topSessions(top.length));
164
318
  for (const s of top) {
165
- 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)))}`);
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))}`);
166
320
  }
167
321
  }
168
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) : '–')}`);
330
+ }
331
+ }
169
332
  for (const f of found) {
170
333
  out.push('', `${f.level === 'warn' ? '⚠' : '•'} ${f.title}${f.tokens ? ` (~${fmt(f.tokens)} tokens, ${pct(f.tokens, r.tokens)})` : ''}`,
171
334
  ` ${f.why}`, ` → ${f.suggestion}`);
@@ -182,4 +345,4 @@ function text(r, found, { scope, paths }) {
182
345
  return out.join('\n');
183
346
  }
184
347
 
185
- 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.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",
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",