agy-insights 0.8.2 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.es.md CHANGED
@@ -23,7 +23,7 @@ Cada fichero que lee el agente y cada salida de comando entra en el contexto y s
23
23
  ## Requisitos
24
24
 
25
25
  - **Node.js 20 o superior** (`node --version`).
26
- - Haber usado **Antigravity CLI** (`agy`) en este equipo. El historial está en `~/.gemini/antigravity-cli/conversations` (en Windows, `%USERPROFILE%\.gemini\antigravity-cli\conversations`).
26
+ - Haber usado **Antigravity** en este equipo: el CLI (`agy`), el editor o la extensión de VS Code. Se leen los dos historiales: el del CLI en `~/.gemini/antigravity-cli/conversations`, y el del editor y la extensión en `~/.gemini/antigravity/conversations` (en Windows, dentro de `%USERPROFILE%\.gemini\`).
27
27
 
28
28
  ## Instalar
29
29
 
@@ -36,7 +36,7 @@ npx agy-insights
36
36
  npx agy-insights@latest --text
37
37
  ```
38
38
 
39
- `@latest` hace que npx compruebe cada vez si hay una versión nueva. Sin él, una copia instalada globalmente (`npm install -g`) o en el proyecto actual tiene prioridad y no se actualiza nunca. Para fijar una versión concreta: `npx agy-insights@0.8.2`.
39
+ `@latest` hace que npx compruebe cada vez si hay una versión nueva. Sin él, una copia instalada globalmente (`npm install -g`) o en el proyecto actual tiene prioridad y no se actualiza nunca. Para fijar una versión concreta: `npx agy-insights@0.9.1`.
40
40
 
41
41
  **Instalándolo globalmente:**
42
42
 
@@ -78,11 +78,11 @@ Analiza todo tu historial (todas las conversaciones que guarde agy, sin límite
78
78
  El informe da dos cifras:
79
79
 
80
80
  - **Consumo real**: los tokens que Antigravity anota en el registro de cada conversación (`~/.gemini/antigravity-cli/brain/<id>/.system_generated/logs/transcript.jsonl`), subagentes incluidos. Se muestra sin caché y con caché, porque no sabemos si la cuota cuenta igual la caché. Es la cifra que sirve para comparar entre compañeros.
81
- - **«~tokens»**: estimados a partir del tamaño guardado de cada paso. Sirven para comparar qué ficheros, carpetas o comandos pesan más, pero se quedan cortos frente al consumo real, porque cada llamada al modelo vuelve a enviar el contexto acumulado.
81
+ - **«~tokens»**: estimados a partir del tamaño guardado de cada paso. Sirven para comparar qué ficheros, carpetas o comandos pesan más, pero se quedan cortos frente al consumo real, porque cada llamada al modelo vuelve a enviar el contexto acumulado. Las imágenes (capturas de pantalla, por ejemplo) se estiman por su tamaño en píxeles, como las cuenta Gemini (258 tokens por bloque de 768 px), y no por sus bytes.
82
82
 
83
83
  ## Leer el informe
84
84
 
85
- Arriba están el selector de periodo y los totales. Después, una gráfica de **consumo de tokens por día** (por semana si el historial pasa de ~4 meses) para ver la evolución. Cámbiala a **Por llamada** para ver los tokens medios de cada llamada al modelo: el total sube y baja según cuánto hayas trabajado, mientras que por llamada solo baja si cada llamada lleva menos contexto, así que es la que muestra si un cambio ha servido. Pasa el ratón por una columna para ver sus cifras, o abre «Ver como tabla». Después, **Modelos y esfuerzo**: llamadas y tokens reales por modelo y nivel de esfuerzo (por ejemplo Gemini 3.8 Flash · Alto), con los tokens por llamada, para ver cuánto cuesta un esfuerzo mayor. Debajo, **Qué está pasando** reúne los hallazgos; al final, el detalle por proyecto, tipo de fichero, carpeta, fichero, herramienta y comando. El proyecto de cada fichero se detecta por su carpeta `.git`.
85
+ Arriba están el selector de periodo y los totales. Después, una gráfica de **consumo de tokens por día** (por semana si el historial pasa de ~4 meses) para ver la evolución. Cámbiala a **Por llamada** para ver los tokens medios de cada llamada al modelo: el total sube y baja según cuánto hayas trabajado, mientras que por llamada solo baja si cada llamada lleva menos contexto, así que es la que muestra si un cambio ha servido. Pasa el ratón por una columna para ver sus cifras, o abre «Ver como tabla». Si usas el CLI y también el IDE de Antigravity, su app o una extensión de editor como la de VS Code, **Por origen** reparte las conversaciones y los tokens reales entre «CLI» e «IDE y extensiones» (estos comparten historial, así que cuentan juntos), y cada sesión indica desde dónde se tuvo. Después, **Modelos y esfuerzo**: llamadas y tokens reales por modelo y nivel de esfuerzo (por ejemplo Gemini 3.8 Flash · Alto), con los tokens por llamada, para ver cuánto cuesta un esfuerzo mayor. Debajo, **Qué está pasando** reúne los hallazgos; al final, el detalle por proyecto, tipo de fichero, carpeta, fichero, herramienta y comando. El proyecto de cada fichero se detecta por su carpeta `.git`.
86
86
 
87
87
  | Hallazgo | Qué significa | Qué hacer |
88
88
  |---|---|---|
@@ -98,7 +98,10 @@ Despliega **Cómo excluirlo** en una propuesta. Verás el fichero exacto, dónde
98
98
 
99
99
  ### 1. Denegar la lectura (permiso de Antigravity)
100
100
 
101
- Es lo único que impide de verdad que el agente lea una ruta. Usa los [permisos oficiales](https://antigravity.google/docs/permissions).
101
+ Es lo único que impide de verdad que el agente lea una ruta. Usa los [permisos oficiales](https://antigravity.google/docs/permissions). Dónde va la regla depende de qué Antigravity leyó el fichero, y el informe propone el sitio correcto:
102
+
103
+ - **IDE, app o extensión de editor** (como la de VS Code): no tienen un fichero de ajustes que editar. Añade la regla (`read_file(<ruta>)`) como regla de denegación en sus **Settings → Permission Grants**, para todos los proyectos o para uno.
104
+ - **CLI (`agy`)**:
102
105
 
103
106
  1. Abre `~/.gemini/antigravity-cli/settings.json` (en Windows, `%USERPROFILE%\.gemini\antigravity-cli\settings.json`).
104
107
  2. Pega la regla dentro de `"permissions"` → `"deny"`. Si no existen, créalas; si existen, añade la regla sin borrar nada:
@@ -116,7 +119,7 @@ Es lo único que impide de verdad que el agente lea una ruta. Usa los [permisos
116
119
 
117
120
  3. Guarda y reinicia `agy`.
118
121
 
119
- Denegar la lectura de una ruta también impide escribir en ella, y la regla vale para todos tus proyectos. Úsala para carpetas que el agente no deba tocar nunca, como salidas de compilación.
122
+ Denegar la lectura de una ruta también impide escribir en ella, y la regla vale para todos tus proyectos. Úsala para carpetas que el agente no deba tocar nunca, como salidas de compilación. Nunca se propone nada de `~/.gemini` ni de las extensiones de editor de Antigravity: Antigravity necesita esos ficheros para funcionar.
120
123
 
121
124
  ### 2. `.antigravityignore`
122
125
 
package/README.md CHANGED
@@ -23,7 +23,7 @@ Every file the agent reads and every command output enters the context and is re
23
23
  ## Requirements
24
24
 
25
25
  - **Node.js 20 or later** (`node --version`).
26
- - Having used **Antigravity CLI** (`agy`) on this machine. The history lives in `~/.gemini/antigravity-cli/conversations` (on Windows, `%USERPROFILE%\.gemini\antigravity-cli\conversations`).
26
+ - Having used **Antigravity** on this machine: the CLI (`agy`), the editor or the VS Code extension. Both histories are read: the CLI's in `~/.gemini/antigravity-cli/conversations`, and the editor's and extension's in `~/.gemini/antigravity/conversations` (on Windows, under `%USERPROFILE%\.gemini\`).
27
27
 
28
28
  ## Install
29
29
 
@@ -36,7 +36,7 @@ npx agy-insights
36
36
  npx agy-insights@latest --text
37
37
  ```
38
38
 
39
- `@latest` makes npx check for the newest version every time. Without it, a copy installed globally (`npm install -g`) or in the current project takes precedence and is never updated. To pin a version instead: `npx agy-insights@0.8.2`.
39
+ `@latest` makes npx check for the newest version every time. Without it, a copy installed globally (`npm install -g`) or in the current project takes precedence and is never updated. To pin a version instead: `npx agy-insights@0.9.1`.
40
40
 
41
41
  **Globally:**
42
42
 
@@ -78,11 +78,11 @@ Analyzes your whole history (every conversation agy has kept, with no date limit
78
78
  The report gives two figures:
79
79
 
80
80
  - **Real usage**: the tokens Antigravity records in each conversation's log (`~/.gemini/antigravity-cli/brain/<id>/.system_generated/logs/transcript.jsonl`), subagents included. It is shown with and without cache, because it is unknown whether the quota counts cache the same way. This is the figure to compare between teammates.
81
- - **"~tokens"**: estimated from the stored size of each step. Useful to compare which files, folders or commands weigh the most, but they fall short of real usage, because every model call resends the accumulated context.
81
+ - **"~tokens"**: estimated from the stored size of each step. Useful to compare which files, folders or commands weigh the most, but they fall short of real usage, because every model call resends the accumulated context. Images (screenshots, for example) are estimated by their size in pixels, as Gemini counts them (258 tokens per 768 px tile), not by their bytes.
82
82
 
83
83
  ## Reading the report
84
84
 
85
- At the top, the period selector and the totals. Then a chart of **token usage per day** (per week when the history spans more than ~4 months), to see how usage evolves. Switch it to **Per call** to see mean tokens per model call: the total follows how much you worked, while per call drops only when each call carries less context, so it is the one that shows whether a change paid off. Hover a column for its figures, or open "View as table". Next, **Models and effort**: calls and real tokens per model and effort level (for example Gemini 3.8 Flash · High), including tokens per call, so you can see what a higher effort costs. Below, **What's happening** lists the findings; at the end, the details by project, file type, folder, file, tool and command. The project of each file is detected through its `.git` folder.
85
+ At the top, the period selector and the totals. Then a chart of **token usage per day** (per week when the history spans more than ~4 months), to see how usage evolves. Switch it to **Per call** to see mean tokens per model call: the total follows how much you worked, while per call drops only when each call carries less context, so it is the one that shows whether a change paid off. Hover a column for its figures, or open "View as table". If you use both the CLI and the Antigravity IDE, app or an editor extension such as VS Code's, **By origin** splits conversations and real tokens between "CLI" and "IDE and extensions" (those share their history, so they count together), and each session shows where it was held. Next, **Models and effort**: calls and real tokens per model and effort level (for example Gemini 3.8 Flash · High), including tokens per call, so you can see what a higher effort costs. Below, **What's happening** lists the findings; at the end, the details by project, file type, folder, file, tool and command. The project of each file is detected through its `.git` folder.
86
86
 
87
87
  | Finding | What it means | What to do |
88
88
  |---|---|---|
@@ -98,7 +98,10 @@ Expand **How to exclude it** on a proposal. You'll see the exact file, where the
98
98
 
99
99
  ### 1. Deny reading it (Antigravity permission)
100
100
 
101
- This is the only thing that actually stops the agent from reading a path. It uses the [official permissions](https://antigravity.google/docs/permissions).
101
+ This is the only thing that actually stops the agent from reading a path. It uses the [official permissions](https://antigravity.google/docs/permissions). Where the rule goes depends on which Antigravity read the file, and the report proposes the right place:
102
+
103
+ - **IDE, app or editor extension** (such as VS Code's): they have no settings file to edit. Add the rule (`read_file(<path>)`) as a deny rule in their **Settings → Permission Grants**, for all projects or for one.
104
+ - **CLI (`agy`)**:
102
105
 
103
106
  1. Open `~/.gemini/antigravity-cli/settings.json` (on Windows, `%USERPROFILE%\.gemini\antigravity-cli\settings.json`).
104
107
  2. Paste the rule inside `"permissions"` → `"deny"`. Create them if they don't exist; if they do, add the rule without removing anything:
@@ -116,7 +119,7 @@ This is the only thing that actually stops the agent from reading a path. It use
116
119
 
117
120
  3. Save and restart `agy`.
118
121
 
119
- Denying reads on a path also blocks writing to it, and the rule applies to all your projects. Use it for folders the agent should never touch, such as build output.
122
+ Denying reads on a path also blocks writing to it, and the rule applies to all your projects. Use it for folders the agent should never touch, such as build output. Nothing under `~/.gemini` or Antigravity's editor extensions is ever proposed: Antigravity needs those files to work.
120
123
 
121
124
  ### 2. `.antigravityignore`
122
125
 
package/agy-insights.js CHANGED
@@ -6,7 +6,7 @@ const fs = require('fs');
6
6
  const os = require('os');
7
7
  const path = require('path');
8
8
  const { spawn } = require('child_process');
9
- const { analyze, DEFAULT_DIR } = require('./lib/history');
9
+ const { analyze, DEFAULT_DIRS } = require('./lib/history');
10
10
  const { findings, DEFAULTS } = require('./lib/findings');
11
11
  const report = require('./lib/report');
12
12
  const { buildMap } = require('./lib/map');
@@ -115,7 +115,7 @@ async function main() {
115
115
  if (!(argv.includes('--lang') && setLang(argv[argv.indexOf('--lang') + 1]))) setLang(detect());
116
116
  const opts = parseArgs(argv);
117
117
  if (opts.lang && !setLang(opts.lang)) fail(t.badLang(opts.lang));
118
- if (opts.help) return console.log(t.help(DEFAULT_DIR));
118
+ if (opts.help) return console.log(t.help(DEFAULT_DIRS.join(' + ')));
119
119
  const cfg = loadConfig(opts.config);
120
120
  if (!opts.lang && cfg.lang && !setLang(cfg.lang)) fail(t.badLang(cfg.lang));
121
121
 
@@ -123,7 +123,7 @@ async function main() {
123
123
  const since = sinceTime(opts);
124
124
  const run = (from) => analyze({
125
125
  since: from,
126
- dir: opts.dir || cfg.conversationsDir || DEFAULT_DIR,
126
+ dir: opts.dir || cfg.conversationsDir || DEFAULT_DIRS,
127
127
  workspaces: opts.project ? [opts.project] : [],
128
128
  customKinds: cfg.customKinds || {},
129
129
  });
@@ -31,7 +31,7 @@ test/*.test.js tests (node --test)
31
31
 
32
32
  It is undocumented; this is what has been observed in `agy` 1.4.x.
33
33
 
34
- - One SQLite database per conversation: `~/.gemini/antigravity-cli/conversations/<uuid>.db`, in WAL mode.
34
+ - One SQLite database per conversation: `~/.gemini/antigravity-cli/conversations/<uuid>.db`, in WAL mode. The editor and the VS Code extension keep the same layout under `~/.gemini/antigravity/` (`conversations/`, `brain/`, `conversation_summaries.db`); both folders are read by default. The editor's summaries also list CLI conversations, sometimes with only a preview, so the first folder's titles win. Conversations stored there as `.pb` files are not read.
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
37
  - Table `gen_metadata`: one row per model call, in the same order as the transcript's `PLANNER_RESPONSE` lines. Each row holds the model's display name with the effort level in brackets (`Gemini 3.8 Flash (High)`) and its id (`gemini-3.8-flash-high`). `modelOf()` takes the model and effort from whole strings only, so prose in the system prompt that mentions a model is ignored, and the n-th row is matched to the n-th call to get tokens per model and effort. That pairing is only trusted when the counts match. Otherwise each call takes the model selected in the transcript (`USER_INPUT` lines carry "changed setting `Model Selection` from X to Y"), or the conversation's only model; what is left is "unknown". `modelKey()` merges the two spellings (`Gemini 3.8 Flash` / `gemini-3.8-flash`, `High` / `high`). `--debug-models` prints, per conversation, how many calls got their model from each source: counts only, safe to paste into an issue.
@@ -102,5 +102,7 @@ node agy-insights.js --dir test/tmp/history
102
102
  ## To do
103
103
 
104
104
  - Confirm with `agy` that a `read_file` `deny` rule blocks the read.
105
+ - Confirm that a deny rule added under Settings → Permission Grants blocks the read in the VS Code extension. Antigravity's own guide (`~/.gemini/antigravity/builtin/skills/antigravity_guide/references/app.md`) documents Permission Grants for the IDE and the app; `~/.gemini/antigravity/` has no `settings.json`, so `fixes.deny()` proposes the CLI's file only for paths the CLI read, and the Settings rule for paths the editor read (from each file's `origins`).
106
+ - Image reads are estimated from the image's pixel size (`imageTokens()`: 258 tokens up to 384×384 px, else 258 per 768 px tile, as Gemini documents), read from the file's header; when the file can't be read, as 4 tiles.
105
107
  - Test with real histories from several developers (Windows and macOS).
106
108
  - Also read the history of the Antigravity app and IDE, if they use the same format.
package/lib/findings.js CHANGED
@@ -55,15 +55,16 @@ function findings(r, opts = {}) {
55
55
  const byFolder = new Map();
56
56
  for (const x of nonSource) {
57
57
  const abs = x.abs.slice(0, x.abs.lastIndexOf('/'));
58
- const e = byFolder.get(abs) || { abs, rel: x.rel.slice(0, x.rel.lastIndexOf('/')), root: x.root, project: x.project, kind: x.kind, tokens: 0, calls: 0 };
58
+ const e = byFolder.get(abs) || { abs, rel: x.rel.slice(0, x.rel.lastIndexOf('/')), root: x.root, project: x.project, kind: x.kind, tokens: 0, calls: 0, origins: [] };
59
59
  e.tokens += x.tokens;
60
60
  e.calls += x.calls;
61
+ for (const o of x.origins || []) if (!e.origins.includes(o)) e.origins.push(o);
61
62
  byFolder.set(abs, e);
62
63
  }
63
64
  const items = [...byFolder.values()].sort((a, b) => b.tokens - a.tokens).slice(0, 10).map((e) => ({
64
65
  label: `${where(e)}/`, tokens: e.tokens, detail: `${kindLabel(e.kind)} · ${f.reads(e.calls)}`,
65
66
  fixes: [
66
- fixes.deny(e.abs),
67
+ ...fixes.deny(e.abs, e.origins),
67
68
  e.root && fixes.ignore(e.root, [`${e.rel}/`]),
68
69
  e.root && fixes.rule(e.root, f.ruleFolder(e.rel, kindLabel(e.kind))),
69
70
  ].filter(Boolean),
@@ -103,7 +104,7 @@ function findings(r, opts = {}) {
103
104
  // Exclusion is only offered for non-source files: denying core source code would break the agent's work.
104
105
  items: cross.slice(0, 10).map((x) => ({
105
106
  label: where(x), tokens: x.tokens, detail: `${f.sessions(x.convs)} · ${f.reads(x.calls)}`,
106
- fixes: nonSourceKinds.has(x.kind) ? [fixes.deny(x.abs), x.root && fixes.ignore(x.root, [x.rel])].filter(Boolean) : [],
107
+ fixes: nonSourceKinds.has(x.kind) ? [...fixes.deny(x.abs, x.origins), x.root && fixes.ignore(x.root, [x.rel])].filter(Boolean) : [],
107
108
  })),
108
109
  });
109
110
  }
package/lib/fixes.js CHANGED
@@ -4,6 +4,9 @@ const fs = require('fs');
4
4
  const os = require('os');
5
5
  const path = require('path');
6
6
  const { t } = require('./i18n');
7
+ const { isAgentFile } = require('./history');
8
+
9
+ const norm = (p) => p.replace(/[\\/]+/g, '/');
7
10
 
8
11
  const AGY_SETTINGS = path.join(os.homedir(), '.gemini', 'antigravity-cli', 'settings.json');
9
12
 
@@ -20,15 +23,33 @@ function deniedRules() {
20
23
 
21
24
  // Permission rule (https://antigravity.google/docs/permissions): deny > ask > allow, a denied read_file
22
25
  // path covers everything below it, and denying a read also denies writes there.
23
- function deny(absPath) {
26
+ // The CLI reads its rules from ~/.gemini/antigravity-cli/settings.json. The IDE, the app and the editor
27
+ // extensions have no such file: their rules are set in their Settings, under Permission Grants. So the
28
+ // proposal follows `origins`, the Antigravity that read the path ('cli', 'editor'; null or none: the CLI's).
29
+ // Antigravity's own files (~/.gemini, its extensions) are never proposed: it needs them to work.
30
+ function deny(absPath, origins = ['cli']) {
31
+ if (isAgentFile(norm(absPath))) return [];
24
32
  const rule = `read_file(${native(absPath)})`;
25
- return {
26
- how: 'deny', title: t.fixes.denyTitle,
27
- file: AGY_SETTINGS, where: t.fixes.denyWhere,
28
- text: JSON.stringify(rule),
29
- note: t.fixes.denyNote,
30
- applied: deniedRules().includes(rule),
31
- };
33
+ const out = [];
34
+ if (!origins.length || origins.some((o) => o !== 'editor')) {
35
+ out.push({
36
+ how: 'deny', title: t.fixes.denyTitle,
37
+ file: AGY_SETTINGS, where: t.fixes.denyWhere,
38
+ text: JSON.stringify(rule),
39
+ note: t.fixes.denyNote,
40
+ applied: deniedRules().includes(rule),
41
+ });
42
+ }
43
+ if (origins.includes('editor')) {
44
+ out.push({
45
+ how: 'deny-editor', title: t.fixes.denyEditorTitle,
46
+ file: t.fixes.denyEditorPlace, where: t.fixes.denyEditorWhere,
47
+ text: rule,
48
+ note: t.fixes.denyEditorNote,
49
+ applied: false,
50
+ });
51
+ }
52
+ return out;
32
53
  }
33
54
 
34
55
  // .antigravityignore: Antigravity's own exclusion file. Its scope is undocumented; it is confirmed to hide
package/lib/history.js CHANGED
@@ -7,7 +7,13 @@ const os = require('os');
7
7
  const path = require('path');
8
8
  const { spawnSync } = require('child_process');
9
9
 
10
- const DEFAULT_DIR = path.join(os.homedir(), '.gemini', 'antigravity-cli', 'conversations');
10
+ // The CLI (agy) keeps its history in antigravity-cli; the editor and the VS Code extension in antigravity,
11
+ // with the same layout. Both are read by default.
12
+ const DEFAULT_DIRS = ['antigravity-cli', 'antigravity'].map((d) => path.join(os.homedir(), '.gemini', d, 'conversations'));
13
+ const DEFAULT_DIR = DEFAULT_DIRS[0];
14
+ // Which Antigravity a history folder belongs to, from its parent's name; null for any other folder.
15
+ // The editor and the VS Code extension share their folder, so they cannot be told apart.
16
+ const originOf = (dir) => ({ 'antigravity-cli': 'cli', antigravity: 'editor' })[path.basename(path.dirname(dir))] || null;
11
17
  const BYTES_PER_TOKEN = 4;
12
18
  const TOOL_RE = /^[a-z][a-z0-9_]{2,40}$/;
13
19
  const FILE_KEYS = ['AbsolutePath', 'TargetFile', 'FilePath', 'File', 'Path'];
@@ -176,6 +182,57 @@ const key = (p) => norm(p).toLowerCase().replace(/\/+$/, '');
176
182
  // Kinds are stable ids; i18n.kindLabel() turns them into text.
177
183
  const NON_SOURCE = new Set(['node_modules', 'build', 'lockfiles', 'logs', 'generated']);
178
184
 
185
+ // Antigravity's own files: ~/.gemini (CLI, editor, brain, skills) and its editor extensions.
186
+ const isAgentFile = (p) => /(^|\/)\.gemini\//i.test(p) || /(^|\/)\.[\w-]+\/extensions\/[^/]*antigravity[^/]*\//i.test(p);
187
+
188
+ // --- Images ---
189
+
190
+ // The model gets images as pixels, not bytes: Gemini counts 258 tokens for an image up to 384×384 px, and
191
+ // 258 per 768×768 tile above that. Their stored size / 4 would count a screenshot as ~100,000 tokens.
192
+ const IMAGE_RE = /\.(png|jpe?g|gif|webp|bmp)$/i;
193
+ const TILE_TOKENS = 258;
194
+ const UNKNOWN_IMAGE_TOKENS = 4 * TILE_TOKENS; // a typical screenshot, when the file can't be read
195
+
196
+ // Width and height from the file's header (PNG, GIF, BMP, WebP, JPEG), or null.
197
+ function imageSize(file) {
198
+ let b;
199
+ try {
200
+ const fd = fs.openSync(file, 'r');
201
+ b = Buffer.alloc(64 * 1024);
202
+ b = b.subarray(0, fs.readSync(fd, b, 0, b.length, 0));
203
+ fs.closeSync(fd);
204
+ } catch { return null; }
205
+ if (b.length >= 24 && b.readUInt32BE(0) === 0x89504e47) return [b.readUInt32BE(16), b.readUInt32BE(20)];
206
+ if (b.length >= 10 && b.toString('latin1', 0, 3) === 'GIF') return [b.readUInt16LE(6), b.readUInt16LE(8)];
207
+ if (b.length >= 26 && b.toString('latin1', 0, 2) === 'BM') return [b.readInt32LE(18), Math.abs(b.readInt32LE(22))];
208
+ if (b.length >= 30 && b.toString('latin1', 0, 4) === 'RIFF' && b.toString('latin1', 8, 12) === 'WEBP') {
209
+ const f = b.toString('latin1', 12, 16);
210
+ if (f === 'VP8X') return [1 + b.readUIntLE(24, 3), 1 + b.readUIntLE(27, 3)];
211
+ if (f === 'VP8 ') return [b.readUInt16LE(26) & 0x3fff, b.readUInt16LE(28) & 0x3fff];
212
+ if (f === 'VP8L') { const v = b.readUInt32LE(21); return [1 + (v & 0x3fff), 1 + ((v >> 14) & 0x3fff)]; }
213
+ }
214
+ if (b[0] === 0xff && b[1] === 0xd8) {
215
+ for (let i = 2; i + 9 < b.length;) {
216
+ if (b[i] !== 0xff) { i++; continue; }
217
+ const m = b[i + 1];
218
+ if (m >= 0xc0 && m <= 0xcf && ![0xc4, 0xc8, 0xcc].includes(m)) return [b.readUInt16BE(i + 7), b.readUInt16BE(i + 5)];
219
+ i += 2 + b.readUInt16BE(i + 2);
220
+ }
221
+ }
222
+ return null;
223
+ }
224
+
225
+ const imageCache = new Map();
226
+ function imageTokens(file) {
227
+ if (!imageCache.has(file)) {
228
+ const size = imageSize(file);
229
+ const [w, h] = size && size[0] > 0 && size[1] > 0 ? size : [0, 0];
230
+ imageCache.set(file, !w ? UNKNOWN_IMAGE_TOKENS
231
+ : w <= 384 && h <= 384 ? TILE_TOKENS : Math.ceil(w / 768) * Math.ceil(h / 768) * TILE_TOKENS);
232
+ }
233
+ return imageCache.get(file);
234
+ }
235
+
179
236
  /**
180
237
  * @param {string} p path with "/" separators
181
238
  * @param {object} [opts]
@@ -184,12 +241,14 @@ const NON_SOURCE = new Set(['node_modules', 'build', 'lockfiles', 'logs', 'gener
184
241
  */
185
242
  function kind(p, { inRepo = false } = {}) {
186
243
  // Antigravity's own data (the agent's brain: plans, artifacts, logs; and skills): it needs them, so they
187
- // are never reported as generated or proposed for exclusion, whatever their names look like.
188
- if (/(^|\/)\.gemini\//i.test(p)) return 'agent';
244
+ // are never reported as generated or proposed for exclusion, whatever their names look like. The same for
245
+ // the Antigravity extension installed in an editor (~/.vscode/extensions/google.google-antigravity-…).
246
+ if (isAgentFile(p)) return 'agent';
189
247
  if (/(^|\/)node_modules\//.test(p)) return 'node_modules';
190
248
  if (!inRepo && /(^|\/)(dist|build|out|target|coverage|allure-results|allure-report|\.next|bin|obj)\//i.test(p)) return 'build';
191
249
  if (/(package-lock\.json|yarn\.lock|pnpm-lock\.yaml|packages\.lock\.json|\.lock)$/.test(p)) return 'lockfiles';
192
250
  if (/\.(log|map)$|\.min\.(js|css)$/.test(p)) return 'logs';
251
+ if (IMAGE_RE.test(p)) return 'images';
193
252
  if (/(^|\/)(tests?|__tests__|specs?)\//i.test(p) || /\.(test|spec|tests?)\.\w+$/i.test(p) || /Tests?\//.test(p)) return 'tests';
194
253
  if (/\.designer\.cs$|\.g\.cs$|\.generated\.\w+$|(^|\/)(generated|Migrations)\//i.test(p)) return 'generated';
195
254
  const ext = path.extname(p).toLowerCase();
@@ -339,26 +398,32 @@ async function conversationTitles(dir, tmp) {
339
398
 
340
399
  class Counter {
341
400
  constructor() { this.m = new Map(); }
342
- // Numeric fields are summed; `meta` is stored the first time a key is seen.
343
- add(k, fields, meta = {}) {
401
+ // Numeric fields are summed; `meta` is stored the first time a key is seen, except `origin`, which is
402
+ // collected into `origins` (every origin that touched the key).
403
+ add(k, fields, { origin, ...meta } = {}) {
344
404
  let e = this.m.get(k);
345
405
  if (!e) this.m.set(k, (e = { key: k, calls: 0, tokens: 0, rereads: 0, rereadTokens: 0, convs: 0, ...meta }));
346
406
  for (const [f, v] of Object.entries(fields)) e[f] = (e[f] || 0) + v;
407
+ if (origin !== undefined && !(e.origins ||= []).includes(origin)) e.origins.push(origin);
347
408
  }
348
409
  all() { return [...this.m.values()].sort((a, b) => b.tokens - a.tokens || b.calls - a.calls); }
349
410
  }
350
411
 
351
412
  /**
352
413
  * @param {object} opts
353
- * @param {string} [opts.dir] conversations directory
414
+ * @param {string|string[]} [opts.dir] conversations directory, or several (the CLI's and the editor's by default)
354
415
  * @param {string[]} [opts.workspaces] only count conversations that touched these folders (all if empty)
355
416
  * @param {Object<string,string>} [opts.customKinds] regex -> label, checked before the built-in types
356
417
  * @param {number} [opts.since] only conversations whose last activity is at or after this time (ms)
357
418
  */
358
- async function analyze({ dir = DEFAULT_DIR, workspaces = [], customKinds = {}, since = null } = {}) {
419
+ async function analyze({ dir: dirs = DEFAULT_DIRS, workspaces = [], customKinds = {}, since = null } = {}) {
420
+ dirs = [].concat(dirs);
421
+ // The folders that exist, for the report's header; all of them when none does, to say where it looked.
422
+ const dir = (dirs.filter((d) => fs.existsSync(d)).length ? dirs.filter((d) => fs.existsSync(d)) : dirs).join(' + ');
359
423
  const custom = Object.entries(customKinds).map(([re, label]) => [new RegExp(re, 'i'), label]);
360
424
  // A file git keeps in the repo is never build output, whatever its folder is called.
361
- const classify = (p, root, rel) => custom.find(([re]) => re.test(p))?.[1]
425
+ // Antigravity's own files stay 'agent' even when a custom type matches them: they must never be excluded.
426
+ const classify = (p, root, rel) => (isAgentFile(p) ? 'agent' : custom.find(([re]) => re.test(p))?.[1])
362
427
  || kind(p, { inRepo: Boolean(root && repoFiles(root)?.has(rel.toLowerCase())) });
363
428
  const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'agy-insights-'));
364
429
  const r = {
@@ -375,12 +440,15 @@ async function analyze({ dir = DEFAULT_DIR, workspaces = [], customKinds = {}, s
375
440
  const wsKeys = workspaces.map(key);
376
441
 
377
442
  try {
378
- const dbs = fs.existsSync(dir) ? fs.readdirSync(dir).filter((f) => /^[0-9a-f-]{36}\.db$/i.test(f)) : [];
379
- const titles = await conversationTitles(dir, tmp);
380
- for (const f of dbs) {
443
+ const dbs = dirs.flatMap((d) => (fs.existsSync(d) ? fs.readdirSync(d).filter((f) => /^[0-9a-f-]{36}\.db$/i.test(f)).map((f) => [d, f]) : []));
444
+ const titles = new Map();
445
+ // The editor's summaries also list CLI conversations, sometimes with just a preview: the first folder's title wins.
446
+ for (const d of dirs) for (const [k, v] of await conversationTitles(d, tmp)) if (!titles.has(k)) titles.set(k, v);
447
+ for (const [dir, f] of dbs) {
381
448
  // When it happened: the transcript's step times, else the database's last write.
382
449
  const id = f.replace(/\.db$/, '');
383
450
  const log = realUsage(dir, id);
451
+ const origin = originOf(dir);
384
452
  const end = log?.end ?? fs.statSync(path.join(dir, f)).mtimeMs;
385
453
  const start = log?.start ?? end;
386
454
  // Real usage from the log when it has token counts; else from gen_metadata (below), else the estimate.
@@ -408,8 +476,16 @@ async function analyze({ dir = DEFAULT_DIR, workspaces = [], customKinds = {}, s
408
476
  convBytes += bytes;
409
477
  // The step payload also carries the tool result: its size approximates what entered the context.
410
478
  toolCalls(strs).forEach((c, i) => {
479
+ const fk = FILE_KEYS.find((k) => typeof c.args[k] === 'string');
480
+ const read = fk && /^(view_file|read_)/.test(c.name) ? norm(c.args[fk]) : null;
411
481
  // A call is counted once (first time its id shows up); every step that carries it adds its size.
412
- const tokens = i === 0 ? bytes / BYTES_PER_TOKEN : 0;
482
+ // An image read counts what the model gets from its pixels, not the bytes stored in the step.
483
+ let tokens = i === 0 ? bytes / BYTES_PER_TOKEN : 0;
484
+ if (tokens && read && IMAGE_RE.test(read)) {
485
+ const image = Math.min(tokens, imageTokens(read));
486
+ convBytes -= (tokens - image) * BYTES_PER_TOKEN;
487
+ tokens = image;
488
+ }
413
489
  const isNew = !seenCalls.has(c.id);
414
490
  if (isNew) seenCalls.set(c.id, 0);
415
491
  const calls = isNew ? 1 : 0;
@@ -418,10 +494,9 @@ async function analyze({ dir = DEFAULT_DIR, workspaces = [], customKinds = {}, s
418
494
  if (c.name === 'run_command' && typeof c.args.CommandLine === 'string') {
419
495
  commands.add(c.args.CommandLine.trim().split(/\s+/).slice(0, 2).join(' '), { calls, tokens });
420
496
  }
421
- const fk = FILE_KEYS.find((k) => typeof c.args[k] === 'string');
422
- if (!fk || !/^(view_file|read_)/.test(c.name)) return;
497
+ if (!read) return;
423
498
 
424
- const file = norm(c.args[fk]);
499
+ const file = read;
425
500
  const { StartLine: s, EndLine: e } = c.args;
426
501
  if (isNew) {
427
502
  const range = `${key(file)}|${s}-${e}`;
@@ -440,8 +515,9 @@ async function analyze({ dir = DEFAULT_DIR, workspaces = [], customKinds = {}, s
440
515
  const fields = { calls, tokens, rereads: isNew ? reread : 0, rereadTokens: reread * tokens };
441
516
  kinds.add(k, { ...fields, convs: once(seenKinds, k) }, { nonSource: NON_SOURCE.has(k) });
442
517
  projects.add(root || '', { ...fields, convs: once(seenProjects, root || '') }, { project });
443
- folders.add(key(folderAbs), { ...fields, convs: once(seenFolders, key(folderAbs)) }, { abs: folderAbs, rel: folderRel, root, project });
444
- files.add(key(file), { ...fields, convs: once(seenFiles, key(file)) }, { abs: file, rel, root, project, kind: k });
518
+ // `origin`: which Antigravity read it (CLI or editor), so exclusions point to the right settings.
519
+ folders.add(key(folderAbs), { ...fields, convs: once(seenFolders, key(folderAbs)) }, { abs: folderAbs, rel: folderRel, root, project, origin });
520
+ files.add(key(file), { ...fields, convs: once(seenFiles, key(file)) }, { abs: file, rel, root, project, kind: k, origin });
445
521
  });
446
522
  }
447
523
  const metaBlobs = db.all('select data from gen_metadata order by idx').map(([blob]) => blob);
@@ -487,7 +563,7 @@ async function analyze({ dir = DEFAULT_DIR, workspaces = [], customKinds = {}, s
487
563
  const main = [...counts].sort((a, b) => b[1] - a[1])[0];
488
564
  const model = main ? models.get(main[0]) : null;
489
565
  r.sessions.push({
490
- id, title: titles.get(id) || null, start, end, steps: steps.length, tokens: convBytes / BYTES_PER_TOKEN, usage,
566
+ id, title: titles.get(id) || null, origin, start, end, steps: steps.length, tokens: convBytes / BYTES_PER_TOKEN, usage,
491
567
  model: model ? { model: model.model, effort: model.effort } : null, models: counts.size,
492
568
  // For --debug-models: where each call's model came from, and for rows where no model was recognized,
493
569
  // the short strings that look like a model id (to spot a new format). Never prompts, paths or code.
@@ -509,8 +585,17 @@ async function analyze({ dir = DEFAULT_DIR, workspaces = [], customKinds = {}, s
509
585
  r.sessions.sort((a, b) => billed(b) - billed(a) || b.tokens - a.tokens);
510
586
  // Many steps but no recognizable tool call: agy probably changed its storage format.
511
587
  r.formatUnrecognized = r.steps > 50 && r.toolCallsFound === 0;
588
+ // Conversations and real usage per origin (CLI, editor), biggest first.
589
+ const origins = new Map();
590
+ for (const s of r.sessions) {
591
+ const o = origins.get(s.origin) || { origin: s.origin, conversations: 0, calls: 0, input: 0, cache: 0, output: 0 };
592
+ o.conversations++;
593
+ if (s.usage) for (const k of ['calls', 'input', 'cache', 'output']) o[k] += s.usage[k];
594
+ origins.set(s.origin, o);
595
+ }
512
596
  return {
513
597
  ...r,
598
+ origins: [...origins.values()].sort((a, b) => b.input + b.output - (a.input + a.output) || b.conversations - a.conversations),
514
599
  tools: tools.all(), kinds: kinds.all(), folders: folders.all(), files: files.all(), projects: projects.all(),
515
600
  commands: commands.all(),
516
601
  models: [...models.values()].sort((a, b) => b.input + b.output - (a.input + a.output) || b.calls - a.calls),
@@ -518,4 +603,4 @@ async function analyze({ dir = DEFAULT_DIR, workspaces = [], customKinds = {}, s
518
603
  };
519
604
  }
520
605
 
521
- module.exports = { analyze, DEFAULT_DIR, kind, NON_SOURCE, realUsage, dayKey, modelOf, selectedModel, repoFiles };
606
+ module.exports = { analyze, DEFAULT_DIR, DEFAULT_DIRS, kind, isAgentFile, imageTokens, NON_SOURCE, realUsage, dayKey, modelOf, selectedModel, repoFiles };
package/lib/i18n.js CHANGED
@@ -21,7 +21,7 @@ Options:
21
21
  --days <n> Only conversations active in the last n days
22
22
  --lang <en|es> Language of the output (default: the system's)
23
23
  --debug-models Per conversation, where each call's model came from (counts only, to report issues)
24
- --dir <folder> Another history folder (default ${dir})
24
+ --dir <folder> Another history folder (default the CLI's and the editor's: ${dir})
25
25
  --config <file> Configuration (default ~/.agy-insights.json if it exists)
26
26
  --help This help
27
27
  `,
@@ -43,14 +43,18 @@ Options:
43
43
 
44
44
  kinds: {
45
45
  agent: "Antigravity's own files (brain, skills)",
46
- build: 'build output (dist, bin, obj…)', logs: 'logs, maps, minified', generated: 'generated code / migrations',
46
+ build: 'build output (dist, bin, obj…)', logs: 'logs, maps, minified', generated: 'generated code / migrations', images: 'images',
47
47
  other: 'other', code: (ext) => `code ${ext}`,
48
48
  },
49
49
 
50
50
  fixes: {
51
- denyTitle: 'Deny reading it (Antigravity permission)',
51
+ denyTitle: 'Deny reading it in the CLI (Antigravity permission)',
52
52
  denyWhere: 'inside "permissions" → "deny"',
53
53
  denyNote: 'This is the only thing that actually blocks the read. It applies to all your projects, also blocks writing to that path, and agy must be restarted.',
54
+ denyEditorTitle: 'Deny reading it in the IDE and extensions (Antigravity permission)',
55
+ denyEditorPlace: 'Settings → Permission Grants',
56
+ denyEditorWhere: 'of the Antigravity IDE, app or editor extension, as a deny rule',
57
+ denyEditorNote: 'These have no settings file to edit: rules are added from their Settings. It blocks the read in all your projects (or only in one, as a project-level grant) and also blocks writing to that path.',
54
58
  ignoreTitle: 'Add to .antigravityignore',
55
59
  ignoreWhere: 'at the end of the file (create it if missing)',
56
60
  ignoreNote: 'Hides it from @ completion; it is not documented to stop the agent from reading it.',
@@ -121,7 +125,12 @@ Options:
121
125
  modelsUnknown: (n) => `"unknown": ${n} calls for which agy recorded no model, neither in gen_metadata nor as a model change in the transcript. If there are many, run npx agy-insights@latest --debug-models and paste the result into an issue on GitHub (it only contains counts).`,
122
126
  efforts: { minimal: 'Minimal', low: 'Low', medium: 'Medium', high: 'High', xhigh: 'Extra high', 'extra high': 'Extra high', max: 'Max' },
123
127
  sessionsTitle: 'Sessions (real usage)',
124
- sessionCols: ['Conversation', 'When', 'Model', 'Tokens', '%', 'With cache', 'Calls', 'Errors'],
128
+ sessionCols: ['Conversation', 'When', 'Origin', 'Model', 'Tokens', '%', 'With cache', 'Calls', 'Errors'],
129
+ origin: { cli: 'CLI', editor: 'IDE and extensions', other: 'Other folder' },
130
+ originsTitle: 'By origin',
131
+ originsHint: 'Where each conversation was held. The Antigravity IDE, the Antigravity app and the editor extensions (such as the VS Code one) keep the same history, so they are counted together.',
132
+ originCols: ['Origin', 'Conversations', 'Calls', 'Tokens', '%', 'Per call', 'With cache'],
133
+ originsText: ' By origin:',
125
134
  noConversations: (scope, dir) => `No conversations${scope ? ` for <code>${scope}</code>` : ''} in <code>${dir}</code>.`,
126
135
  tileReal: 'real tokens (input + output)',
127
136
  tileCache: (calls) => `including cache · ${plural(calls, 'call', 'calls')}`,
@@ -197,7 +206,7 @@ Opciones:
197
206
  --days <n> Solo conversaciones con actividad en los últimos n días
198
207
  --lang <en|es> Idioma de la salida (por defecto, el del sistema)
199
208
  --debug-models Por conversación, de dónde sale el modelo de cada llamada (solo recuentos, para reportar fallos)
200
- --dir <carpeta> Otra carpeta de historial (por defecto ${dir})
209
+ --dir <carpeta> Otra carpeta de historial (por defecto, la del CLI y la del editor: ${dir})
201
210
  --config <fichero> Configuración (por defecto ~/.agy-insights.json si existe)
202
211
  --help Esta ayuda
203
212
  `,
@@ -219,14 +228,18 @@ Opciones:
219
228
 
220
229
  kinds: {
221
230
  agent: 'ficheros propios de Antigravity (brain, skills)',
222
- build: 'build output (dist, bin, obj…)', logs: 'logs, maps, minificados', generated: 'código generado / migraciones',
231
+ build: 'build output (dist, bin, obj…)', logs: 'logs, maps, minificados', generated: 'código generado / migraciones', images: 'imágenes',
223
232
  other: 'otros', code: (ext) => `código ${ext}`,
224
233
  },
225
234
 
226
235
  fixes: {
227
- denyTitle: 'Denegar la lectura (permiso de Antigravity)',
236
+ denyTitle: 'Denegar la lectura en el CLI (permiso de Antigravity)',
228
237
  denyWhere: 'dentro de "permissions" → "deny"',
229
238
  denyNote: 'Es lo único que impide la lectura. Afecta a todos tus proyectos, también impide escribir en esa ruta, y hay que reiniciar agy.',
239
+ denyEditorTitle: 'Denegar la lectura en el IDE y extensiones (permiso de Antigravity)',
240
+ denyEditorPlace: 'Settings → Permission Grants',
241
+ denyEditorWhere: 'del IDE, la app o la extensión de Antigravity, como regla de denegación',
242
+ denyEditorNote: 'No tienen un fichero de ajustes que editar: la regla se añade desde sus Settings. Impide la lectura en todos tus proyectos (o solo en uno, como permiso del proyecto) y también impide escribir en esa ruta.',
230
243
  ignoreTitle: 'Añadir a .antigravityignore',
231
244
  ignoreWhere: 'al final del fichero (créalo si no existe)',
232
245
  ignoreNote: 'Lo oculta del autocompletado con @; no está documentado que impida leerlo.',
@@ -297,7 +310,12 @@ Opciones:
297
310
  modelsUnknown: (n) => `«desconocido»: ${n} llamadas de las que agy no anotó el modelo, ni en gen_metadata ni como cambio de modelo en el transcript. Si son muchas, ejecuta npx agy-insights@latest --debug-models y pega el resultado en una incidencia de GitHub (solo contiene recuentos).`,
298
311
  efforts: { minimal: 'Mínimo', low: 'Bajo', medium: 'Medio', high: 'Alto', xhigh: 'Muy alto', 'extra high': 'Muy alto', max: 'Máximo' },
299
312
  sessionsTitle: 'Sesiones (consumo real)',
300
- sessionCols: ['Conversación', 'Cuándo', 'Modelo', 'Tokens', '%', 'Con caché', 'Llamadas', 'Errores'],
313
+ sessionCols: ['Conversación', 'Cuándo', 'Origen', 'Modelo', 'Tokens', '%', 'Con caché', 'Llamadas', 'Errores'],
314
+ origin: { cli: 'CLI', editor: 'IDE y extensiones', other: 'Otra carpeta' },
315
+ originsTitle: 'Por origen',
316
+ originsHint: 'Desde dónde se tuvo cada conversación. El IDE de Antigravity, la app de Antigravity y las extensiones de editor (como la de VS Code) guardan el mismo historial, así que se cuentan juntos.',
317
+ originCols: ['Origen', 'Conversaciones', 'Llamadas', 'Tokens', '%', 'Por llamada', 'Con caché'],
318
+ originsText: ' Por origen:',
301
319
  noConversations: (scope, dir) => `No hay conversaciones${scope ? ` de <code>${scope}</code>` : ''} en <code>${dir}</code>.`,
302
320
  tileReal: 'tokens reales (entrada + salida)',
303
321
  tileCache: (calls) => `contando la caché · ${plural(calls, 'llamada', 'llamadas')}`,
package/lib/report.js CHANGED
@@ -58,12 +58,17 @@ function table(title, rows, total, name = label) {
58
58
  </tbody></table></div>`;
59
59
  }
60
60
 
61
+ // Origins are only told apart when the history mixes several (CLI and editor).
62
+ const mixed = (r) => r.origins.length > 1;
63
+ const originName = (o) => t.report.origin[o] || t.report.origin.other;
64
+
61
65
  function sessionsTable(r) {
62
66
  const rows = r.sessions.filter((s) => s.usage).slice(0, 15);
63
67
  if (!rows.length) return '';
64
68
  const max = Math.max(...rows.map((s) => billed(s.usage)), 1);
65
- return `<h3>${t.report.sessionsTitle}</h3><div class="scroll"><table><thead><tr>${t.report.sessionCols.map((c, i) => `<th${i > 2 ? ' class="num"' : ''}>${c}</th>`).join('')}</tr></thead><tbody>
66
- ${rows.map((s) => `<tr><td><div class="bar" style="--w:${((100 * billed(s.usage)) / max).toFixed(1)}%"></div><span>${esc(s.title || s.id.slice(0, 8))}</span>${s.title ? `<span class="detail">${esc(s.id.slice(0, 8))}</span>` : ''}</td><td class="when">${esc(when(s.start, s.end))}</td><td>${esc(sessionModel(s))}</td>
69
+ const cols = mixed(r) ? t.report.sessionCols : t.report.sessionCols.filter((_, i) => i !== 2);
70
+ return `<h3>${t.report.sessionsTitle}</h3><div class="scroll"><table><thead><tr>${cols.map((c, i) => `<th${i > cols.length - 6 ? ' class="num"' : ''}>${c}</th>`).join('')}</tr></thead><tbody>
71
+ ${rows.map((s) => `<tr><td><div class="bar" style="--w:${((100 * billed(s.usage)) / max).toFixed(1)}%"></div><span>${esc(s.title || s.id.slice(0, 8))}</span>${s.title ? `<span class="detail">${esc(s.id.slice(0, 8))}</span>` : ''}</td><td class="when">${esc(when(s.start, s.end))}</td>${mixed(r) ? `<td class="when">${esc(originName(s.origin))}</td>` : ''}<td>${esc(sessionModel(s))}</td>
67
72
  <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>
68
73
  <td class="num">${s.usage.calls}</td><td class="num">${s.usage.errors || ''}</td></tr>`).join('')}
69
74
  </tbody></table></div>`;
@@ -88,6 +93,19 @@ function modelsTable(r) {
88
93
  </tbody></table></div>${unknown ? `<p class="hint">${esc(t.report.modelsUnknown(fmt(unknown.calls)))}</p>` : ''}</section>`;
89
94
  }
90
95
 
96
+ // Conversations and real usage per origin (CLI, editor / VS Code), when the history mixes them.
97
+ function originsTable(r) {
98
+ if (!mixed(r)) return '';
99
+ const total = billed(r.usage);
100
+ const max = Math.max(...r.origins.map(billed), 1);
101
+ return `<section class="origins"><h2>${t.report.originsTitle}</h2><p class="hint">${t.report.originsHint}</p>
102
+ <div class="scroll"><table><thead><tr>${t.report.originCols.map((c, i) => `<th${i ? ' class="num"' : ''}>${c}</th>`).join('')}</tr></thead><tbody>
103
+ ${r.origins.map((o) => `<tr><td><div class="bar" style="--w:${((100 * billed(o)) / max).toFixed(1)}%"></div><span>${esc(originName(o.origin))}</span></td>
104
+ <td class="num">${o.conversations}</td><td class="num">${fmt(o.calls)}</td><td class="num">${fmt(billed(o))}</td><td class="num">${pct(billed(o), total)}</td>
105
+ <td class="num">${o.calls ? fmt(billed(o) / o.calls) : '–'}</td><td class="num">${fmt(withCache(o))}</td></tr>`).join('')}
106
+ </tbody></table></div></section>`;
107
+ }
108
+
91
109
  // --- Usage chart ---
92
110
 
93
111
  const DAY = 24 * 60 * 60 * 1000;
@@ -188,6 +206,7 @@ function body(r, found, scope) {
188
206
  <div><b>${pct(r.rereads, r.reads)}</b><span>${t.report.tileRereads}</span></div>
189
207
  </div>
190
208
  ${chart(r)}
209
+ ${originsTable(r)}
191
210
  ${modelsTable(r)}
192
211
  <h2>${t.report.happening}</h2>
193
212
  <p class="hint">${t.report.happeningHint}</p>
@@ -270,7 +289,7 @@ function htmlViews(views, { scope }) {
270
289
  .copy:focus-visible { outline: 2px solid var(--accent); }
271
290
  .scroll { overflow-x: auto; } table { width: 100%; border-collapse: collapse; font-size: 14px; margin: 6px 0 18px; }
272
291
  th { text-align: left; color: var(--muted); font-weight: 600; } th, td { padding: 5px 8px; border-bottom: 1px solid var(--line); }
273
- td:first-child { position: relative; word-break: break-all; } td.when { white-space: nowrap; }
292
+ td:first-child { position: relative; overflow-wrap: anywhere; } td.when { white-space: nowrap; }
274
293
  .bar { position: absolute; inset: 3px auto 3px 0; width: var(--w); background: var(--bar); opacity: .15; border-radius: 3px; } td span { position: relative; }
275
294
  .detail-all > summary { color: var(--fg); }
276
295
  footer { margin-top: 32px; font-size: 13px; color: var(--muted); }
@@ -347,7 +366,11 @@ function text(r, found, { scope, paths }) {
347
366
  const top = r.sessions.filter((s) => s.usage).slice(0, 5);
348
367
  out.push(t.report.topSessions(top.length));
349
368
  for (const s of top) {
350
- out.push(` ${fmt(billed(s.usage)).padStart(10)} ${pct(billed(s.usage), billed(u)).padStart(4)} ${t.report.sessionLine(s.id.slice(0, 8), s.usage.calls, s.usage.errors, fmt(withCache(s.usage)), sessionModel(s), when(s.start, s.end), s.title)}`);
369
+ out.push(` ${fmt(billed(s.usage)).padStart(10)} ${pct(billed(s.usage), billed(u)).padStart(4)} ${t.report.sessionLine(s.id.slice(0, 8), s.usage.calls, s.usage.errors, fmt(withCache(s.usage)), sessionModel(s), [when(s.start, s.end), mixed(r) && originName(s.origin)].filter(Boolean).join(' · '), s.title)}`);
370
+ }
371
+ if (mixed(r)) {
372
+ out.push(t.report.originsText);
373
+ for (const o of r.origins) out.push(` ${fmt(billed(o)).padStart(10)} ${pct(billed(o), billed(u)).padStart(4)} ${originName(o.origin)} (${t.report.convs(o.conversations)})`);
351
374
  }
352
375
  }
353
376
  out.push(t.report.top5(pct(top5, r.tokens), r.reads, r.rereads, pct(r.rereads, r.reads)));
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "agy-insights",
3
- "version": "0.8.2",
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",
3
+ "version": "0.9.1",
4
+ "description": "Measures Antigravity token usage (CLI, editor and VS Code extension) 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",