agy-insights 0.4.0 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.es.md +19 -16
- package/README.md +19 -16
- package/agy-insights.js +35 -3
- package/docs/how-it-works.md +9 -1
- package/lib/history.js +105 -16
- package/lib/i18n.js +89 -23
- package/lib/map.js +7 -6
- package/lib/report.js +198 -35
- package/package.json +2 -2
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
|
|
14
|
-
npx agy-insights --text
|
|
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.
|
|
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.1`.
|
|
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
|
-
|
|
|
72
|
-
|
|
|
73
|
-
|
|
|
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,12 +82,12 @@ 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
|
|---|---|---|
|
|
86
89
|
| El agente vuelve a leer lo que ya tenía | Lee otra vez un trozo de fichero que ya estaba en la conversación | Sesiones más cortas, una por tarea, o `/compact`. No excluyas el fichero: lo necesita. |
|
|
87
|
-
| Ficheros generados, compilados o de dependencias | Lecturas de `bin/`, `obj/`, `dist/`, `node_modules`, migraciones, lockfiles o logs | Si no hacían falta, exclúyelos (ver abajo) |
|
|
90
|
+
| Ficheros generados, compilados o de dependencias | Lecturas de `bin/`, `obj/`, `dist/`, `node_modules`, migraciones, lockfiles o logs (nunca cuentan los ficheros propios de Antigravity en `~/.gemini`, como su brain, ni una carpeta `build/` o `bin/` que git mantiene en el repo, como una configuración de webpack) | Si no hacían falta, exclúyelos (ver abajo) |
|
|
88
91
|
| Los mismos ficheros entran en muchas sesiones | Un fichero se lee en varias conversaciones distintas | Si es código central es normal; si es grande, dile al agente qué parte mirar |
|
|
89
92
|
| Comandos con salidas muy largas | La salida de tests o builds entra entera | Pide ejecutar solo el test o proyecto afectado |
|
|
90
93
|
| Sesiones muy largas | Todo lo acumulado se reenvía en cada turno | Una conversación nueva por tarea |
|
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
|
|
14
|
-
npx agy-insights --text
|
|
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.
|
|
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.1`.
|
|
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
|
-
|
|
|
72
|
-
|
|
|
73
|
-
|
|
|
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,12 +82,12 @@ The report gives two figures:
|
|
|
79
82
|
|
|
80
83
|
## Reading the report
|
|
81
84
|
|
|
82
|
-
|
|
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
|
|---|---|---|
|
|
86
89
|
| The agent re-reads what it already had | It reads again a chunk of a file that was already in the conversation | Shorter sessions, one per task, or `/compact`. Don't exclude the file: the agent needs it. |
|
|
87
|
-
| Generated, build or dependency files are being read | Reads from `bin/`, `obj/`, `dist/`, `node_modules`, migrations, lockfiles or logs | If they weren't needed, exclude them (see below) |
|
|
90
|
+
| Generated, build or dependency files are being read | Reads from `bin/`, `obj/`, `dist/`, `node_modules`, migrations, lockfiles or logs (Antigravity's own files in `~/.gemini`, such as its brain, never count; nor does a `build/` or `bin/` folder that git keeps in the repo, like a webpack config) | If they weren't needed, exclude them (see below) |
|
|
88
91
|
| The same files enter many sessions | A file is read in several different conversations | Normal for core code; if it is large, tell the agent which part to look at |
|
|
89
92
|
| Commands with very long output | The full output of tests or builds enters the context | Ask it to run only the affected test or project |
|
|
90
93
|
| Very long sessions | Everything accumulated is resent on every turn | A new conversation per task |
|
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
|
|
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
|
|
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
|
-
|
|
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
|
}
|
package/docs/how-it-works.md
CHANGED
|
@@ -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`:
|
|
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
|
|
@@ -54,6 +58,10 @@ The project of each file is detected by walking up its path until a folder with
|
|
|
54
58
|
|
|
55
59
|
All user-facing text lives in `lib/i18n.js`, in an `en` and an `es` table with the same keys (a test checks it). Modules read it through `t`, which always points at the current language. The language comes from `--lang`, then the `lang` config key, then the system locale (`LC_ALL`, `LC_MESSAGES`, `LANG`, `LANGUAGE`, or `Intl`): Spanish if it starts with `es`, English otherwise. Numbers follow it too (`en-US` / `es-ES`).
|
|
56
60
|
|
|
61
|
+
Folder names alone are not enough for build output: `build/webpack/` or `bin/` often hold sources. When the file's repo is on this machine, `repoFiles()` asks git for the files it does not ignore (`git ls-files --cached --others --exclude-standard`, once per repo), and a file in that list is never kind `build`. `--map` does the same, since it lists files through git. Without git, or for a repo that is not on this machine, the folder name decides.
|
|
62
|
+
|
|
63
|
+
Anything under `~/.gemini/` is kind `agent`: the agent's own brain (plans, artifacts, its logs) and skills. It is checked first and is not in `NON_SOURCE`, so those reads are never reported as generated files or proposed for exclusion, even when a path contains `out/` or ends in `.log`.
|
|
64
|
+
|
|
57
65
|
Built-in file kinds are stable ids (`build`, `logs`, `generated`, `code .cs`…) and are only turned into text when shown, with `kindLabel()`. Kinds from `customKinds` are shown as the user wrote them.
|
|
58
66
|
|
|
59
67
|
To add a message, add it to both tables. To add a language, add a table and teach `detect()` to pick it.
|
package/lib/history.js
CHANGED
|
@@ -5,12 +5,29 @@
|
|
|
5
5
|
const fs = require('fs');
|
|
6
6
|
const os = require('os');
|
|
7
7
|
const path = require('path');
|
|
8
|
+
const { spawnSync } = require('child_process');
|
|
8
9
|
|
|
9
10
|
const DEFAULT_DIR = path.join(os.homedir(), '.gemini', 'antigravity-cli', 'conversations');
|
|
10
11
|
const BYTES_PER_TOKEN = 4;
|
|
11
12
|
const TOOL_RE = /^[a-z][a-z0-9_]{2,40}$/;
|
|
12
13
|
const FILE_KEYS = ['AbsolutePath', 'TargetFile', 'FilePath', 'File', 'Path'];
|
|
13
|
-
|
|
14
|
+
// Model of a call, as agy names it in gen_metadata: "Gemini 3.8 Flash (High)" (name and effort level), or
|
|
15
|
+
// the id "gemini-3.8-flash-high" when the display name is missing.
|
|
16
|
+
// Whole strings only, so that prose in the system prompt that mentions a model is not taken for one.
|
|
17
|
+
const MODEL_NAME_RE = /^((?:Gemini|Claude|GPT)[\w .-]*?) \(([A-Za-z][A-Za-z ]{1,15})\)$/;
|
|
18
|
+
const MODEL_ID_RE = /^((?:gemini|claude|gpt)-[\w.-]*?)-(minimal|low|medium|high|xhigh|max)$/;
|
|
19
|
+
|
|
20
|
+
function modelOf(strs) {
|
|
21
|
+
for (const s of strs) {
|
|
22
|
+
const m = s.length < 200 && MODEL_NAME_RE.exec(s);
|
|
23
|
+
if (m) return { model: m[1].trim(), effort: m[2].trim() };
|
|
24
|
+
}
|
|
25
|
+
for (const s of strs) {
|
|
26
|
+
const m = s.length < 200 && MODEL_ID_RE.exec(s);
|
|
27
|
+
if (m) return { model: m[1], effort: m[2] };
|
|
28
|
+
}
|
|
29
|
+
return { model: null, effort: null };
|
|
30
|
+
}
|
|
14
31
|
|
|
15
32
|
// --- SQLite access: node:sqlite when the runtime has it (Node >= 22.13), sql.js (WASM) otherwise. ---
|
|
16
33
|
|
|
@@ -109,9 +126,18 @@ const key = (p) => norm(p).toLowerCase().replace(/\/+$/, '');
|
|
|
109
126
|
// Kinds are stable ids; i18n.kindLabel() turns them into text.
|
|
110
127
|
const NON_SOURCE = new Set(['node_modules', 'build', 'lockfiles', 'logs', 'generated']);
|
|
111
128
|
|
|
112
|
-
|
|
129
|
+
/**
|
|
130
|
+
* @param {string} p path with "/" separators
|
|
131
|
+
* @param {object} [opts]
|
|
132
|
+
* @param {boolean} [opts.inRepo] git says the file is part of the repo (not ignored): a build/ or bin/
|
|
133
|
+
* folder then holds sources (webpack config, CLI scripts), not build output.
|
|
134
|
+
*/
|
|
135
|
+
function kind(p, { inRepo = false } = {}) {
|
|
136
|
+
// Antigravity's own data (the agent's brain: plans, artifacts, logs; and skills): it needs them, so they
|
|
137
|
+
// are never reported as generated or proposed for exclusion, whatever their names look like.
|
|
138
|
+
if (/(^|\/)\.gemini\//i.test(p)) return 'agent';
|
|
113
139
|
if (/(^|\/)node_modules\//.test(p)) return 'node_modules';
|
|
114
|
-
if (/(^|\/)(dist|build|out|target|coverage|\.next|bin|obj)\//i.test(p)) return 'build';
|
|
140
|
+
if (!inRepo && /(^|\/)(dist|build|out|target|coverage|\.next|bin|obj)\//i.test(p)) return 'build';
|
|
115
141
|
if (/(package-lock\.json|yarn\.lock|pnpm-lock\.yaml|packages\.lock\.json|\.lock)$/.test(p)) return 'lockfiles';
|
|
116
142
|
if (/\.(log|map)$|\.min\.(js|css)$/.test(p)) return 'logs';
|
|
117
143
|
if (/(^|\/)(tests?|__tests__|specs?)\//i.test(p) || /\.(test|spec|tests?)\.\w+$/i.test(p) || /Tests?\//.test(p)) return 'tests';
|
|
@@ -120,6 +146,17 @@ function kind(p) {
|
|
|
120
146
|
return ext ? `code ${ext}` : 'other';
|
|
121
147
|
}
|
|
122
148
|
|
|
149
|
+
// Files of a git repo that git does not ignore (tracked, or untracked and not ignored), lowercased and
|
|
150
|
+
// relative to the root; null when git cannot tell (no git, or the repo is not on this machine).
|
|
151
|
+
const repoFilesCache = new Map();
|
|
152
|
+
function repoFiles(root) {
|
|
153
|
+
if (!repoFilesCache.has(root)) {
|
|
154
|
+
const git = spawnSync('git', ['ls-files', '--cached', '--others', '--exclude-standard', '-z'], { cwd: root, encoding: 'utf8', maxBuffer: 1 << 28, windowsHide: true });
|
|
155
|
+
repoFilesCache.set(root, git.status === 0 ? new Set(git.stdout.split('\0').filter(Boolean).map((f) => f.toLowerCase())) : null);
|
|
156
|
+
}
|
|
157
|
+
return repoFilesCache.get(root);
|
|
158
|
+
}
|
|
159
|
+
|
|
123
160
|
// Project root of a file: an explicit workspace containing it, or else the nearest folder with a .git
|
|
124
161
|
// entry, when the path exists on this machine. Null when unknown (e.g. a repo that was deleted).
|
|
125
162
|
const rootCache = new Map();
|
|
@@ -154,6 +191,9 @@ function locate(file, workspaces, depth = 3) {
|
|
|
154
191
|
|
|
155
192
|
// --- Real usage ---
|
|
156
193
|
|
|
194
|
+
// Local calendar day of a time, as YYYY-MM-DD.
|
|
195
|
+
const dayKey = (ms) => { const d = new Date(ms); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; };
|
|
196
|
+
|
|
157
197
|
// What the model was actually billed for in a conversation, from the transcript agy keeps next to the
|
|
158
198
|
// history (<conversations>/../brain/<id>/.system_generated/logs/transcript.jsonl): one line per step,
|
|
159
199
|
// and model calls carry input, cache-read and output tokens. Subagents have their own conversation and
|
|
@@ -162,16 +202,28 @@ function realUsage(dir, id) {
|
|
|
162
202
|
const file = path.join(dir, '..', 'brain', id, '.system_generated', 'logs', 'transcript.jsonl');
|
|
163
203
|
let text;
|
|
164
204
|
try { text = fs.readFileSync(file, 'utf8'); } catch { return null; }
|
|
165
|
-
|
|
205
|
+
// `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).
|
|
207
|
+
const u = { calls: 0, input: 0, cache: 0, output: 0, errors: 0, start: null, end: null, days: new Map(), perCall: [] };
|
|
166
208
|
for (const line of text.split('\n')) {
|
|
167
209
|
let e;
|
|
168
210
|
try { e = JSON.parse(line); } catch { continue; }
|
|
211
|
+
const at = Date.parse(e.created_at);
|
|
212
|
+
if (at) { u.start = Math.min(u.start ?? at, at); u.end = Math.max(u.end ?? at, at); }
|
|
169
213
|
if (e.status === 'ERROR') u.errors++;
|
|
170
214
|
if (e.type !== 'PLANNER_RESPONSE') continue;
|
|
171
215
|
u.calls++;
|
|
172
216
|
u.input += Number(e.input_tokens) || 0;
|
|
173
217
|
u.cache += Number(e.cache_read_tokens) || 0;
|
|
174
218
|
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 });
|
|
220
|
+
const k = at ? dayKey(at) : null;
|
|
221
|
+
const d = u.days.get(k) || { calls: 0, input: 0, cache: 0, output: 0 };
|
|
222
|
+
d.calls++;
|
|
223
|
+
d.input += Number(e.input_tokens) || 0;
|
|
224
|
+
d.cache += Number(e.cache_read_tokens) || 0;
|
|
225
|
+
d.output += Number(e.output_tokens) || 0;
|
|
226
|
+
u.days.set(k, d);
|
|
175
227
|
}
|
|
176
228
|
return u;
|
|
177
229
|
}
|
|
@@ -194,23 +246,36 @@ class Counter {
|
|
|
194
246
|
* @param {string} [opts.dir] conversations directory
|
|
195
247
|
* @param {string[]} [opts.workspaces] only count conversations that touched these folders (all if empty)
|
|
196
248
|
* @param {Object<string,string>} [opts.customKinds] regex -> label, checked before the built-in types
|
|
249
|
+
* @param {number} [opts.since] only conversations whose last activity is at or after this time (ms)
|
|
197
250
|
*/
|
|
198
|
-
async function analyze({ dir = DEFAULT_DIR, workspaces = [], customKinds = {} } = {}) {
|
|
251
|
+
async function analyze({ dir = DEFAULT_DIR, workspaces = [], customKinds = {}, since = null } = {}) {
|
|
199
252
|
const custom = Object.entries(customKinds).map(([re, label]) => [new RegExp(re, 'i'), label]);
|
|
200
|
-
|
|
253
|
+
// A file git keeps in the repo is never build output, whatever its folder is called.
|
|
254
|
+
const classify = (p, root, rel) => custom.find(([re]) => re.test(p))?.[1]
|
|
255
|
+
|| kind(p, { inRepo: Boolean(root && repoFiles(root)?.has(rel.toLowerCase())) });
|
|
201
256
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'agy-insights-'));
|
|
202
257
|
const r = {
|
|
203
258
|
dir, backend: nodeSqlite ? 'node:sqlite' : 'sql.js', conversations: 0, unreadable: 0, steps: 0, tokens: 0,
|
|
259
|
+
since, period: null, olderSkipped: 0,
|
|
204
260
|
toolCallsFound: 0, reads: 0, rangedReads: 0, linesRead: 0, rereads: 0, rereadTokens: 0, sessions: [],
|
|
205
261
|
usage: { conversations: 0, calls: 0, input: 0, cache: 0, output: 0, errors: 0 },
|
|
206
262
|
};
|
|
207
263
|
const tools = new Counter(), kinds = new Counter(), folders = new Counter(), files = new Counter();
|
|
208
|
-
const commands = new Counter(),
|
|
264
|
+
const commands = new Counter(), projects = new Counter();
|
|
265
|
+
const models = new Map(); // "model|effort" -> calls and real tokens
|
|
266
|
+
const daily = new Map(); // YYYY-MM-DD -> real usage that day
|
|
267
|
+
const firstDay = since ? dayKey(since) : '';
|
|
209
268
|
const wsKeys = workspaces.map(key);
|
|
210
269
|
|
|
211
270
|
try {
|
|
212
271
|
const dbs = fs.existsSync(dir) ? fs.readdirSync(dir).filter((f) => /^[0-9a-f-]{36}\.db$/i.test(f)) : [];
|
|
213
272
|
for (const f of dbs) {
|
|
273
|
+
// When it happened: the transcript's step times, else the database's last write.
|
|
274
|
+
const id = f.replace(/\.db$/, '');
|
|
275
|
+
const usage = realUsage(dir, id);
|
|
276
|
+
const end = usage?.end ?? fs.statSync(path.join(dir, f)).mtimeMs;
|
|
277
|
+
const start = usage?.start ?? end;
|
|
278
|
+
if (since && end < since) { r.olderSkipped++; continue; }
|
|
214
279
|
let db;
|
|
215
280
|
try { db = await openDb(path.join(dir, f), tmp); } catch { r.unreadable++; continue; }
|
|
216
281
|
try {
|
|
@@ -223,6 +288,7 @@ async function analyze({ dir = DEFAULT_DIR, workspaces = [], customKinds = {} }
|
|
|
223
288
|
}
|
|
224
289
|
|
|
225
290
|
r.conversations++;
|
|
291
|
+
r.period = { from: Math.min(r.period?.from ?? start, start), to: Math.max(r.period?.to ?? end, end) };
|
|
226
292
|
let convBytes = 0;
|
|
227
293
|
const seenRanges = new Set(), seenFiles = new Set(), seenFolders = new Set(), seenKinds = new Set(), seenProjects = new Set();
|
|
228
294
|
const seenCalls = new Map(); // call id -> 1 if that call re-read a range already read, else 0
|
|
@@ -260,7 +326,7 @@ async function analyze({ dir = DEFAULT_DIR, workspaces = [], customKinds = {} }
|
|
|
260
326
|
|
|
261
327
|
// Folders and files are keyed by absolute path: several projects can share relative paths.
|
|
262
328
|
const { root, project, rel, folderRel, folderAbs } = locate(file, workspaces);
|
|
263
|
-
const k = classify(file);
|
|
329
|
+
const k = classify(file, root, rel);
|
|
264
330
|
const fields = { calls, tokens, rereads: isNew ? reread : 0, rereadTokens: reread * tokens };
|
|
265
331
|
kinds.add(k, { ...fields, convs: once(seenKinds, k) }, { nonSource: NON_SOURCE.has(k) });
|
|
266
332
|
projects.add(root || '', { ...fields, convs: once(seenProjects, root || '') }, { project });
|
|
@@ -268,17 +334,38 @@ async function analyze({ dir = DEFAULT_DIR, workspaces = [], customKinds = {} }
|
|
|
268
334
|
files.add(key(file), { ...fields, convs: once(seenFiles, key(file)) }, { abs: file, rel, root, project, kind: k });
|
|
269
335
|
});
|
|
270
336
|
}
|
|
271
|
-
const id = f.replace(/\.db$/, '');
|
|
272
|
-
const usage = realUsage(dir, id);
|
|
273
337
|
if (usage) {
|
|
274
338
|
r.usage.conversations++;
|
|
275
339
|
for (const k of ['calls', 'input', 'cache', 'output', 'errors']) r.usage[k] += usage[k];
|
|
340
|
+
for (const [k, v] of usage.days) {
|
|
341
|
+
const day = k || dayKey(end);
|
|
342
|
+
if (day < firstDay) continue;
|
|
343
|
+
const d = daily.get(day) || { day, calls: 0, input: 0, cache: 0, output: 0 };
|
|
344
|
+
for (const f of ['calls', 'input', 'cache', 'output']) d[f] += v[f];
|
|
345
|
+
daily.set(day, d);
|
|
346
|
+
}
|
|
276
347
|
}
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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 };
|
|
356
|
+
e.calls++;
|
|
357
|
+
if (!seenModels.has(k)) { seenModels.add(k); e.convs++; }
|
|
358
|
+
const c = usage?.perCall[i];
|
|
359
|
+
if (c) { e.known++; e.input += c.input; e.cache += c.cache; e.output += c.output; }
|
|
360
|
+
models.set(k, e);
|
|
281
361
|
}
|
|
362
|
+
// 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
|
+
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 });
|
|
368
|
+
r.tokens += convBytes / BYTES_PER_TOKEN;
|
|
282
369
|
} catch { r.unreadable++; } finally { db.close(); }
|
|
283
370
|
}
|
|
284
371
|
} finally {
|
|
@@ -293,8 +380,10 @@ async function analyze({ dir = DEFAULT_DIR, workspaces = [], customKinds = {} }
|
|
|
293
380
|
return {
|
|
294
381
|
...r,
|
|
295
382
|
tools: tools.all(), kinds: kinds.all(), folders: folders.all(), files: files.all(), projects: projects.all(),
|
|
296
|
-
commands: commands.all(),
|
|
383
|
+
commands: commands.all(),
|
|
384
|
+
models: [...models.values()].sort((a, b) => b.input + b.output - (a.input + a.output) || b.calls - a.calls),
|
|
385
|
+
daily: [...daily.values()].sort((a, b) => (a.day < b.day ? -1 : 1)),
|
|
297
386
|
};
|
|
298
387
|
}
|
|
299
388
|
|
|
300
|
-
module.exports = { analyze, DEFAULT_DIR, kind, NON_SOURCE, realUsage };
|
|
389
|
+
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,13 +27,16 @@ 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
|
|
36
|
+
done: (convs, found, out) => `${plural(convs, 'conversation', 'conversations')} analyzed, ${plural(found, 'finding', 'findings')}.\nReport: ${out}`,
|
|
33
37
|
|
|
34
38
|
kinds: {
|
|
39
|
+
agent: "Antigravity's own files (brain, skills)",
|
|
35
40
|
build: 'build output (dist, bin, obj…)', logs: 'logs, maps, minified', generated: 'generated code / migrations',
|
|
36
41
|
other: 'other', code: (ext) => `code ${ext}`,
|
|
37
42
|
},
|
|
@@ -78,7 +83,7 @@ Options:
|
|
|
78
83
|
longSuggestion: 'A new conversation per task, or /compact when you change topic.',
|
|
79
84
|
conversation: (id) => `conversation ${id}`,
|
|
80
85
|
steps: (n) => plural(n, 'step', 'steps'),
|
|
81
|
-
longUsage: (calls, real, cached) => ` · ${calls
|
|
86
|
+
longUsage: (calls, real, cached) => ` · ${plural(calls, 'model call', 'model calls')}, ${real} real tokens (${cached} with cache)`,
|
|
82
87
|
},
|
|
83
88
|
|
|
84
89
|
map: {
|
|
@@ -99,11 +104,20 @@ Options:
|
|
|
99
104
|
exampleRule: 'Example rule for AGENTS.md',
|
|
100
105
|
exampleHint: "Add it to the project's AGENTS.md only if this happens often: rules are sent with every request.",
|
|
101
106
|
cols: ['What', '~tokens', '%', 'Times', 'Sessions'],
|
|
107
|
+
modelsTitle: 'Models and effort',
|
|
108
|
+
modelsHint: 'Model and effort level (reasoning) of each model call, as agy records them. Higher effort usually means more tokens per call.',
|
|
109
|
+
modelCols: ['Model', 'Effort', 'Calls', 'Tokens', '%', 'Per call', 'With cache', 'Sessions'],
|
|
110
|
+
unknownModel: 'unknown',
|
|
111
|
+
modelsText: 'Models and effort:',
|
|
112
|
+
modelLine: (model, effort, calls, tokens, pct, perCall) => `${model} · ${effort} ${plural(calls, 'call', 'calls')}, ${tokens} tokens (${pct}), ~${perCall} per call`,
|
|
113
|
+
sessionModel: 'Model',
|
|
114
|
+
otherModels: (n) => ` +${n} more`,
|
|
115
|
+
efforts: { minimal: 'Minimal', low: 'Low', medium: 'Medium', high: 'High', xhigh: 'Extra high', 'extra high': 'Extra high', max: 'Max' },
|
|
102
116
|
sessionsTitle: 'Sessions (real usage)',
|
|
103
|
-
sessionCols: ['Conversation', 'Tokens', '%', 'With cache', 'Calls', 'Errors'],
|
|
117
|
+
sessionCols: ['Conversation', 'Model', 'Tokens', '%', 'With cache', 'Calls', 'Errors'],
|
|
104
118
|
noConversations: (scope, dir) => `No conversations${scope ? ` for <code>${scope}</code>` : ''} in <code>${dir}</code>.`,
|
|
105
119
|
tileReal: 'real tokens (input + output)',
|
|
106
|
-
tileCache: (calls) => `including cache · ${calls
|
|
120
|
+
tileCache: (calls) => `including cache · ${plural(calls, 'call', 'calls')}`,
|
|
107
121
|
tileContext: '~tokens in context',
|
|
108
122
|
tileTop5: 'in the 5 largest sessions',
|
|
109
123
|
tileReads: 'file reads',
|
|
@@ -116,18 +130,35 @@ Options:
|
|
|
116
130
|
byKind: 'By file type', byFolder: 'By folder', files: 'Files', tools: 'Tools', commands: 'Commands',
|
|
117
131
|
title: 'Context usage in Antigravity',
|
|
118
132
|
project: 'Project', wholeHistory: 'Whole history',
|
|
119
|
-
|
|
120
|
-
|
|
133
|
+
convs: (n) => plural(n, 'conversation', 'conversations'),
|
|
134
|
+
generated: (date) => `generated ${date}`,
|
|
135
|
+
filterLabel: 'Period',
|
|
136
|
+
rangeAll: 'All history',
|
|
137
|
+
rangeDays: (n) => `Last ${n} days`,
|
|
138
|
+
chartTitleDay: 'Token usage per day',
|
|
139
|
+
chartTitleWeek: 'Token usage per week',
|
|
140
|
+
chartHint: 'Real tokens (input + output) of the model calls. Hover a column for details.',
|
|
141
|
+
chartTable: 'View as table',
|
|
142
|
+
chartCols: ['Day', 'Tokens', 'With cache', 'Calls'],
|
|
143
|
+
chartColsWeek: ['Week of', 'Tokens', 'With cache', 'Calls'],
|
|
144
|
+
chartCalls: (n) => plural(n, 'call', 'calls'),
|
|
145
|
+
weekOf: (d) => `week of ${d}`,
|
|
146
|
+
withCacheShort: 'with cache',
|
|
147
|
+
period: (from, to) => (from === to ? `on ${from}` : `from ${from} to ${to}`),
|
|
148
|
+
since: (date) => `active since ${date}`,
|
|
149
|
+
olderSkipped: (n) => `${plural(n, 'older conversation', 'older conversations')} left out by the date filter`,
|
|
150
|
+
periodLine: (period, since) => `Period: ${period}${since ? ` (filter: ${since})` : ''}`,
|
|
151
|
+
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
152
|
footerCache: 'It is unknown whether the quota counts cache the same as new input, so both figures are shown.',
|
|
122
153
|
footerEstimate: '~tokens = stored size of each step / 4: useful to compare files and folders with each other.',
|
|
123
154
|
footerPrivacy: 'The history was read from a copy and was not modified. This page contains paths from your projects: review it before sharing.',
|
|
124
155
|
// Text summary
|
|
125
156
|
history: 'History', projectLower: 'project',
|
|
126
|
-
summary: (convs, steps, tokens) => `${convs
|
|
127
|
-
realUsage: (real, input, output, cached, calls) => `Real usage: ${real} tokens (input ${input} + output ${output}), ${cached} including cache, in ${calls
|
|
128
|
-
noLog: (n) => ` (${n
|
|
129
|
-
topSessions: (n) => ` The ${n} sessions that use the most
|
|
130
|
-
sessionLine: (id, calls, errors, cached) => `conversation ${id} ${calls
|
|
157
|
+
summary: (convs, steps, tokens) => `${plural(convs, 'conversation', 'conversations')}, ${plural(steps, 'step', 'steps')}, ~${tokens} tokens in context (estimated)`,
|
|
158
|
+
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
|
+
noLog: (n) => ` (${plural(n, 'conversation', 'conversations')} without a log)`,
|
|
160
|
+
topSessions: (n) => (n === 1 ? ' The session that uses the most:' : ` The ${n} sessions that use the most:`),
|
|
161
|
+
sessionLine: (id, calls, errors, cached, model) => `conversation ${id}${model ? ` (${model})` : ''} ${plural(calls, 'call', 'calls')}${errors ? `, ${plural(errors, 'error', 'errors')}` : ''}, ${cached} with cache`,
|
|
131
162
|
top5: (pct, reads, rereads, rpct) => `The 5 largest sessions account for ${pct}. Reads: ${reads}, re-reads ${rereads} (${rpct})`,
|
|
132
163
|
pathHidden: '(path hidden, use --paths)',
|
|
133
164
|
byKindText: 'By file type:',
|
|
@@ -148,6 +179,8 @@ Opciones:
|
|
|
148
179
|
--map Imprime un mapa del proyecto (--project, o la carpeta actual) para AGENTS.md
|
|
149
180
|
--out <fichero> Dónde guardar el informe HTML (por defecto, en la carpeta temporal)
|
|
150
181
|
--no-open No abrir el navegador
|
|
182
|
+
--since <AAAA-MM-DD> Solo conversaciones con actividad desde esa fecha
|
|
183
|
+
--days <n> Solo conversaciones con actividad en los últimos n días
|
|
151
184
|
--lang <en|es> Idioma de la salida (por defecto, el del sistema)
|
|
152
185
|
--dir <carpeta> Otra carpeta de historial (por defecto ${dir})
|
|
153
186
|
--config <fichero> Configuración (por defecto ~/.agy-insights.json si existe)
|
|
@@ -156,13 +189,16 @@ Opciones:
|
|
|
156
189
|
missingValue: (opt) => `Falta el valor de ${opt}`,
|
|
157
190
|
unknownOption: (opt) => `Opción desconocida: ${opt}. Usa --help.`,
|
|
158
191
|
badLang: (v) => `Idioma no soportado: ${v}. Usa en o es.`,
|
|
192
|
+
badDate: (v) => `Fecha no válida: ${v}. Usa AAAA-MM-DD.`,
|
|
193
|
+
badDays: (v) => `Número de días no válido: ${v}.`,
|
|
159
194
|
configNotFound: (f) => `No existe el fichero de configuración ${f}`,
|
|
160
195
|
cannotRead: (f, msg) => `No se puede leer ${f}: ${msg}`,
|
|
161
196
|
mapDone: (tokens, file) => `\n~${tokens} tokens en cada petición. Pégalo en ${file} si te convence; agy-insights no lo escribe.`,
|
|
162
197
|
reportName: 'informe',
|
|
163
|
-
done: (convs, found, out) => `${convs
|
|
198
|
+
done: (convs, found, out) => `${plural(convs, 'conversación analizada', 'conversaciones analizadas')}, ${plural(found, 'hallazgo', 'hallazgos')}.\nInforme: ${out}`,
|
|
164
199
|
|
|
165
200
|
kinds: {
|
|
201
|
+
agent: 'ficheros propios de Antigravity (brain, skills)',
|
|
166
202
|
build: 'build output (dist, bin, obj…)', logs: 'logs, maps, minificados', generated: 'código generado / migraciones',
|
|
167
203
|
other: 'otros', code: (ext) => `código ${ext}`,
|
|
168
204
|
},
|
|
@@ -209,7 +245,7 @@ Opciones:
|
|
|
209
245
|
longSuggestion: 'Una conversación nueva por tarea, o /compact cuando cambias de tema.',
|
|
210
246
|
conversation: (id) => `conversación ${id}`,
|
|
211
247
|
steps: (n) => plural(n, 'paso', 'pasos'),
|
|
212
|
-
longUsage: (calls, real, cached) => ` · ${calls
|
|
248
|
+
longUsage: (calls, real, cached) => ` · ${plural(calls, 'llamada', 'llamadas')} al modelo, ${real} tokens reales (${cached} con caché)`,
|
|
213
249
|
},
|
|
214
250
|
|
|
215
251
|
map: {
|
|
@@ -230,11 +266,20 @@ Opciones:
|
|
|
230
266
|
exampleRule: 'Regla de ejemplo para AGENTS.md',
|
|
231
267
|
exampleHint: 'Ponla en el AGENTS.md del proyecto solo si te pasa a menudo: las reglas se envían en cada petición.',
|
|
232
268
|
cols: ['Qué', '~tokens', '%', 'Veces', 'Sesiones'],
|
|
269
|
+
modelsTitle: 'Modelos y esfuerzo',
|
|
270
|
+
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.',
|
|
271
|
+
modelCols: ['Modelo', 'Esfuerzo', 'Llamadas', 'Tokens', '%', 'Por llamada', 'Con caché', 'Sesiones'],
|
|
272
|
+
unknownModel: 'desconocido',
|
|
273
|
+
modelsText: 'Modelos y esfuerzo:',
|
|
274
|
+
modelLine: (model, effort, calls, tokens, pct, perCall) => `${model} · ${effort} ${plural(calls, 'llamada', 'llamadas')}, ${tokens} tokens (${pct}), ~${perCall} por llamada`,
|
|
275
|
+
sessionModel: 'Modelo',
|
|
276
|
+
otherModels: (n) => ` +${n} más`,
|
|
277
|
+
efforts: { minimal: 'Mínimo', low: 'Bajo', medium: 'Medio', high: 'Alto', xhigh: 'Muy alto', 'extra high': 'Muy alto', max: 'Máximo' },
|
|
233
278
|
sessionsTitle: 'Sesiones (consumo real)',
|
|
234
|
-
sessionCols: ['Conversación', 'Tokens', '%', 'Con caché', 'Llamadas', 'Errores'],
|
|
279
|
+
sessionCols: ['Conversación', 'Modelo', 'Tokens', '%', 'Con caché', 'Llamadas', 'Errores'],
|
|
235
280
|
noConversations: (scope, dir) => `No hay conversaciones${scope ? ` de <code>${scope}</code>` : ''} en <code>${dir}</code>.`,
|
|
236
281
|
tileReal: 'tokens reales (entrada + salida)',
|
|
237
|
-
tileCache: (calls) => `contando la caché · ${calls
|
|
282
|
+
tileCache: (calls) => `contando la caché · ${plural(calls, 'llamada', 'llamadas')}`,
|
|
238
283
|
tileContext: '~tokens en contexto',
|
|
239
284
|
tileTop5: 'en las 5 sesiones más grandes',
|
|
240
285
|
tileReads: 'lecturas de ficheros',
|
|
@@ -247,17 +292,34 @@ Opciones:
|
|
|
247
292
|
byKind: 'Por tipo de fichero', byFolder: 'Por carpeta', files: 'Ficheros', tools: 'Herramientas', commands: 'Comandos',
|
|
248
293
|
title: 'Uso de contexto en Antigravity',
|
|
249
294
|
project: 'Proyecto', wholeHistory: 'Todo el historial',
|
|
250
|
-
|
|
251
|
-
|
|
295
|
+
convs: (n) => plural(n, 'conversación', 'conversaciones'),
|
|
296
|
+
generated: (date) => `generado ${date}`,
|
|
297
|
+
filterLabel: 'Periodo',
|
|
298
|
+
rangeAll: 'Todo el historial',
|
|
299
|
+
rangeDays: (n) => `Últimos ${n} días`,
|
|
300
|
+
chartTitleDay: 'Consumo de tokens por día',
|
|
301
|
+
chartTitleWeek: 'Consumo de tokens por semana',
|
|
302
|
+
chartHint: 'Tokens reales (entrada + salida) de las llamadas al modelo. Pasa el ratón por una columna para ver el detalle.',
|
|
303
|
+
chartTable: 'Ver como tabla',
|
|
304
|
+
chartCols: ['Día', 'Tokens', 'Con caché', 'Llamadas'],
|
|
305
|
+
chartColsWeek: ['Semana del', 'Tokens', 'Con caché', 'Llamadas'],
|
|
306
|
+
chartCalls: (n) => plural(n, 'llamada', 'llamadas'),
|
|
307
|
+
weekOf: (d) => `semana del ${d}`,
|
|
308
|
+
withCacheShort: 'con caché',
|
|
309
|
+
period: (from, to) => (from === to ? `el ${from}` : `del ${from} al ${to}`),
|
|
310
|
+
since: (date) => `con actividad desde el ${date}`,
|
|
311
|
+
olderSkipped: (n) => `${plural(n, 'conversación anterior queda fuera', 'conversaciones anteriores quedan fuera')} por el filtro de fechas`,
|
|
312
|
+
periodLine: (period, since) => `Periodo: ${period}${since ? ` (filtro: ${since})` : ''}`,
|
|
313
|
+
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
314
|
footerCache: 'No se sabe si la cuota cuenta la caché igual que la entrada nueva: por eso se muestran las dos cifras.',
|
|
253
315
|
footerEstimate: '~tokens = tamaño guardado de cada paso / 4: sirve para comparar ficheros y carpetas entre sí.',
|
|
254
316
|
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
317
|
history: 'Historial', projectLower: 'proyecto',
|
|
256
|
-
summary: (convs, steps, tokens) => `${convs
|
|
257
|
-
realUsage: (real, input, output, cached, calls) => `Consumo real: ${real} tokens (entrada ${input} + salida ${output}), ${cached} contando la caché, en ${calls
|
|
258
|
-
noLog: (n) => ` (${n
|
|
259
|
-
topSessions: (n) => ` Las ${n} sesiones que más gastan
|
|
260
|
-
sessionLine: (id, calls, errors, cached) => `conversación ${id} ${calls
|
|
318
|
+
summary: (convs, steps, tokens) => `${plural(convs, 'conversación', 'conversaciones')}, ${plural(steps, 'paso', 'pasos')}, ~${tokens} tokens en contexto (estimado)`,
|
|
319
|
+
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
|
+
noLog: (n) => ` (${plural(n, 'conversación', 'conversaciones')} sin registro)`,
|
|
321
|
+
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) => `conversación ${id}${model ? ` (${model})` : ''} ${plural(calls, 'llamada', 'llamadas')}${errors ? `, ${plural(errors, 'error', 'errores')}` : ''}, ${cached} con caché`,
|
|
261
323
|
top5: (pct, reads, rereads, rpct) => `Las 5 sesiones más grandes suman el ${pct}. Lecturas: ${reads}, relecturas ${rereads} (${rpct})`,
|
|
262
324
|
pathHidden: '(ruta oculta, usa --paths)',
|
|
263
325
|
byKindText: 'Por tipo de fichero:',
|
|
@@ -283,6 +345,7 @@ function setLang(lang) {
|
|
|
283
345
|
const t = new Proxy({}, { get: (_, k) => LANGS[current][k] });
|
|
284
346
|
const lang = () => current;
|
|
285
347
|
const num = (n) => Math.round(n).toLocaleString(t.locale);
|
|
348
|
+
const date = (ms) => new Date(ms).toLocaleDateString(t.locale, { day: 'numeric', month: 'short', year: 'numeric' });
|
|
286
349
|
|
|
287
350
|
// Built-in kinds are stable ids (see history.js kind()); custom kinds from the config are shown as written.
|
|
288
351
|
function kindLabel(k) {
|
|
@@ -290,4 +353,7 @@ function kindLabel(k) {
|
|
|
290
353
|
return t.kinds[k] || k;
|
|
291
354
|
}
|
|
292
355
|
|
|
293
|
-
|
|
356
|
+
// "High" / "high" → "High" or "Alto"; levels agy may add later are shown as written.
|
|
357
|
+
const effortLabel = (e) => (e ? t.report.efforts[e.toLowerCase()] || e : t.report.unknownModel);
|
|
358
|
+
|
|
359
|
+
module.exports = { t, setLang, lang, detect, num, date, kindLabel, effortLabel, LANGS };
|
package/lib/map.js
CHANGED
|
@@ -15,10 +15,10 @@ const SKIP_DIRS = new Set(['.git', 'node_modules', '.vs', '.vscode', '.idea', '.
|
|
|
15
15
|
const BYTES_PER_TOKEN = 4;
|
|
16
16
|
|
|
17
17
|
// Project files relative to `root`, with "/" separators: tracked plus untracked-but-not-ignored when
|
|
18
|
-
// it is a git repo, else a plain walk.
|
|
18
|
+
// it is a git repo (`inRepo`: none of them is ignored build output), else a plain walk.
|
|
19
19
|
function listFiles(root) {
|
|
20
20
|
const git = spawnSync('git', ['ls-files', '--cached', '--others', '--exclude-standard', '-z'], { cwd: root, encoding: 'utf8', maxBuffer: 1 << 28 });
|
|
21
|
-
if (git.status === 0) return [...new Set(git.stdout.split('\0').filter(Boolean))];
|
|
21
|
+
if (git.status === 0) return { files: [...new Set(git.stdout.split('\0').filter(Boolean))], inRepo: true };
|
|
22
22
|
const out = [];
|
|
23
23
|
const walk = (rel) => {
|
|
24
24
|
for (const e of fs.readdirSync(path.join(root, rel), { withFileTypes: true })) {
|
|
@@ -27,7 +27,7 @@ function listFiles(root) {
|
|
|
27
27
|
}
|
|
28
28
|
};
|
|
29
29
|
walk('');
|
|
30
|
-
return out;
|
|
30
|
+
return { files: out, inRepo: false };
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
// Line count of a text file; null for binaries or unreadable files.
|
|
@@ -71,11 +71,12 @@ function mainFolders(files, maxFolders) {
|
|
|
71
71
|
*/
|
|
72
72
|
function buildMap(root, opts = {}) {
|
|
73
73
|
const o = { ...DEFAULTS, ...opts };
|
|
74
|
-
const all = listFiles(root);
|
|
74
|
+
const { files: all, inRepo } = listFiles(root);
|
|
75
75
|
const generated = new Set(), source = [];
|
|
76
76
|
for (const f of all) {
|
|
77
|
-
const
|
|
78
|
-
|
|
77
|
+
const nonSource = NON_SOURCE.has(kind(f, { inRepo }));
|
|
78
|
+
const g = nonSource ? generatedFolder(f) : null;
|
|
79
|
+
if (g) generated.add(g); else if (!nonSource) source.push(f);
|
|
79
80
|
}
|
|
80
81
|
const reads = new Map((o.history || []).filter((h) => h.root).map((h) => [`${h.root}/${h.rel}`.toLowerCase(), h.calls]));
|
|
81
82
|
const rootNorm = root.replace(/[\\/]+/g, '/').replace(/\/$/, '');
|
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
|
|
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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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
|
-
|
|
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
|
|
69
|
-
|
|
70
|
-
:
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
</div>
|
|
78
|
-
<
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
161
|
+
const meta = `<p class="meta">${t.report.convs(r.conversations)}${r.period ? ` · ${esc(periodLine(r))}` : ''}</p>`;
|
|
162
|
+
if (!r.conversations) {
|
|
163
|
+
return `${meta}<p>${t.report.noConversations(scope && esc(scope), esc(r.dir))}${r.olderSkipped ? ` ${esc(t.report.olderSkipped(r.olderSkipped))}.` : ''}</p>`;
|
|
164
|
+
}
|
|
165
|
+
return `${meta}<div class="tiles">
|
|
166
|
+
${u.conversations ? `<div><b>${fmt(billed(u))}</b><span>${t.report.tileReal}</span></div>
|
|
167
|
+
<div><b>${fmt(withCache(u))}</b><span>${t.report.tileCache(u.calls)}</span></div>` : ''}
|
|
168
|
+
<div><b>${fmt(r.tokens)}</b><span>${t.report.tileContext}</span></div>
|
|
169
|
+
<div><b>${pct(top5, r.tokens)}</b><span>${t.report.tileTop5}</span></div>
|
|
170
|
+
<div><b>${r.reads}</b><span>${t.report.tileReads}</span></div>
|
|
171
|
+
<div><b>${pct(r.rereads, r.reads)}</b><span>${t.report.tileRereads}</span></div>
|
|
172
|
+
</div>
|
|
173
|
+
${chart(r)}
|
|
174
|
+
${modelsTable(r)}
|
|
175
|
+
<h2>${t.report.happening}</h2>
|
|
176
|
+
<p class="hint">${t.report.happeningHint}</p>
|
|
177
|
+
${found.length ? found.map((f) => finding(f, r.tokens)).join('') : `<p>${t.report.nothing}</p>`}
|
|
178
|
+
<details class="detail-all"><summary><h2>${t.report.details}</h2></summary>
|
|
179
|
+
${sessionsTable(r)}
|
|
180
|
+
${table(t.report.byProject, r.projects, r.tokens, (x) => x.project || t.report.outsideGit)}
|
|
181
|
+
${table(t.report.byKind, r.kinds, r.tokens, (x) => kindLabel(x.key))}
|
|
182
|
+
${table(t.report.byFolder, r.folders, r.tokens)}
|
|
183
|
+
${table(t.report.files, r.files, r.tokens)}
|
|
184
|
+
${table(t.report.tools, r.tools, r.tokens, (x) => x.key)}
|
|
185
|
+
${table(t.report.commands, r.commands, r.tokens, (x) => x.key)}
|
|
186
|
+
</details>`;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* One report per period, switched in the browser by a row of presets. Each period is analyzed on its own,
|
|
191
|
+
* so every number, finding and table below the filter agrees with it.
|
|
192
|
+
* @param {{ key: string, label: string, r: object, found: object[] }[]} views the first one shows by default
|
|
193
|
+
*/
|
|
194
|
+
function htmlViews(views, { scope }) {
|
|
195
|
+
const u = views[0].r.usage;
|
|
196
|
+
const filter = views.length > 1
|
|
197
|
+
? `<div class="filter" role="group" aria-label="${esc(t.report.filterLabel)}"><span>${t.report.filterLabel}</span>
|
|
198
|
+
${views.map((v, i) => `<button type="button" data-view="${v.key}" aria-pressed="${i === 0}">${esc(v.label)}</button>`).join('')}</div>`
|
|
199
|
+
: '';
|
|
200
|
+
const sections = views.map((v, i) => `<div class="view" data-view="${v.key}"${i ? ' hidden' : ''}>${body(v.r, v.found, scope)}</div>`).join('');
|
|
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
|
-
.
|
|
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
|
|
132
|
-
${
|
|
133
|
-
|
|
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
|
-
"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.1",
|
|
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",
|