agy-insights 0.8.1 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.es.md CHANGED
@@ -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.1`.
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.0`.
40
40
 
41
41
  **Instalándolo globalmente:**
42
42
 
@@ -82,7 +82,7 @@ El informe da dos cifras:
82
82
 
83
83
  ## Leer el informe
84
84
 
85
- Arriba están el selector de periodo y los totales. Después, una gráfica de **consumo de tokens por día** (por semana si el historial pasa de ~4 meses) para ver la evolución. 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 editor o la extensión de VS Code, **Por origen** reparte las conversaciones y los tokens reales entre ellos (el editor y la extensión 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
  |---|---|---|
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.1`.
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.0`.
40
40
 
41
41
  **Globally:**
42
42
 
@@ -82,7 +82,7 @@ The report gives two figures:
82
82
 
83
83
  ## Reading the report
84
84
 
85
- At the top, the period selector and the totals. Then a chart of **token usage per day** (per week when the history spans more than ~4 months), to see how usage evolves. 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 editor or the VS Code extension, **By origin** splits conversations and real tokens between them (the editor and the extension 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
  |---|---|---|
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,11 +31,11 @@ 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.
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.
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. Some logs (seen with the VS Code plugin) have `PLANNER_RESPONSE` lines without any token field; those are not counted as 0 tokens. The same figures are also in each `gen_metadata` row, at protobuf field 1 › 4 (2 = input, 3 = output, 5 = cache read; the model's display name is at 1 › 21). When the transcript has no counts, or there is no transcript, `usageFromMeta()` takes real usage from there, keeping the day and selected model of the transcript's n-th call when there is one.
39
39
 
40
40
  Each `transcript.jsonl` line also has `created_at` (ISO time). A conversation is dated by the first and last of those; without a transcript, by the `.db` file's last write. `--since` / `--days` keep a conversation when its last activity falls inside the period, and skip the rest before opening them. `../conversation_summaries.db` has one row per conversation: its short `title` (or `preview` in older ones) names each session in the report instead of its id; `last_modified_time` is not used. If the file is missing, sessions are shown by id. Each model call's `created_at` also puts its tokens on a local day (`r.daily`), which feeds the usage chart.
41
41
 
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'];
@@ -114,6 +120,39 @@ function collect(blob) {
114
120
  return out;
115
121
  }
116
122
 
123
+ // The top-level fields of one protobuf message: tag -> varint value or bytes (last one wins). Null if malformed.
124
+ function fields(buf) {
125
+ const out = new Map();
126
+ let i = 0;
127
+ const varint = () => {
128
+ let v = 0, s = 0, b;
129
+ do { if (i >= buf.length) throw 0; b = buf[i++]; v += (b & 0x7f) * 2 ** s; s += 7; } while (b & 0x80);
130
+ return v;
131
+ };
132
+ try {
133
+ while (i < buf.length) {
134
+ const k = varint(), tag = Math.floor(k / 8), wt = k & 7;
135
+ if (wt === 0) out.set(tag, varint());
136
+ else if (wt === 1) i += 8;
137
+ else if (wt === 5) i += 4;
138
+ else if (wt === 2) { const len = varint(); if (i + len > buf.length) throw 0; out.set(tag, buf.subarray(i, i + len)); i += len; } else throw 0;
139
+ }
140
+ return out;
141
+ } catch { return null; }
142
+ }
143
+
144
+ // Token counts of one gen_metadata row (one model call): field 1 › 4 holds 2 = input, 3 = output,
145
+ // 5 = cache read, the same figures the transcript logs. Null when the row has none: some logs (seen
146
+ // with the VS Code plugin) carry no token counts, and this is then the only place that has them.
147
+ function metaUsage(blob) {
148
+ const top = blob && fields(Buffer.from(blob));
149
+ const call = Buffer.isBuffer(top?.get(1)) && fields(top.get(1));
150
+ const u = Buffer.isBuffer(call?.get(4)) && fields(call.get(4));
151
+ if (!u || !(u.has(2) || u.has(3))) return null;
152
+ const n = (k) => (typeof u.get(k) === 'number' ? u.get(k) : 0);
153
+ return { input: n(2), output: n(3), cache: n(5) };
154
+ }
155
+
117
156
  // A tool call is stored as the tool name followed (within a few strings) by its JSON arguments,
118
157
  // e.g. "view_file" + {"AbsolutePath":"C:\\dev\\...\\X.cs","StartLine":301,"EndLine":380}.
119
158
  // The call id ("call_5859111") just before the name identifies the call: agy stores each call twice,
@@ -242,7 +281,7 @@ function realUsage(dir, id) {
242
281
  if (!['input_tokens', 'cache_read_tokens', 'output_tokens'].some((k) => k in e)) {
243
282
  u.unmetered++;
244
283
  for (const k of Object.keys(e)) if (k.length < 40) u.unmeteredKeys.add(k);
245
- u.perCall.push({ metered: false, model: selected });
284
+ u.perCall.push({ metered: false, model: selected, at });
246
285
  continue;
247
286
  }
248
287
  u.calls++;
@@ -261,6 +300,27 @@ function realUsage(dir, id) {
261
300
  return u;
262
301
  }
263
302
 
303
+ // Real usage rebuilt from gen_metadata, one row per model call, for conversations whose log has no token
304
+ // counts (or no log). Each row's call keeps the model and day of the log's n-th call when there is one.
305
+ function usageFromMeta(blobs, log, end) {
306
+ const rows = blobs.map(metaUsage);
307
+ if (!rows.some(Boolean)) return null;
308
+ const u = { calls: 0, input: 0, cache: 0, output: 0, errors: log?.errors || 0, start: log?.start ?? null, end: log?.end ?? null,
309
+ days: new Map(), perCall: [], unmetered: 0, unmeteredKeys: new Set(), unrecognized: log?.unrecognized || new Set(), fromMeta: true };
310
+ rows.forEach((x, i) => {
311
+ const from = log?.perCall[i];
312
+ if (!x) { u.perCall.push({ metered: false, model: from?.model || null }); return; }
313
+ u.calls++;
314
+ u.input += x.input; u.cache += x.cache; u.output += x.output;
315
+ u.perCall.push({ metered: true, ...x, model: from?.model || null });
316
+ const k = dayKey(from?.at || log?.end || end);
317
+ const d = u.days.get(k) || { calls: 0, input: 0, cache: 0, output: 0 };
318
+ d.calls++; d.input += x.input; d.cache += x.cache; d.output += x.output;
319
+ u.days.set(k, d);
320
+ });
321
+ return u;
322
+ }
323
+
264
324
  // --- Titles ---
265
325
 
266
326
  // The short title agy gives each conversation ("Analyzing Package Lock Dependencies"), from
@@ -296,12 +356,15 @@ class Counter {
296
356
 
297
357
  /**
298
358
  * @param {object} opts
299
- * @param {string} [opts.dir] conversations directory
359
+ * @param {string|string[]} [opts.dir] conversations directory, or several (the CLI's and the editor's by default)
300
360
  * @param {string[]} [opts.workspaces] only count conversations that touched these folders (all if empty)
301
361
  * @param {Object<string,string>} [opts.customKinds] regex -> label, checked before the built-in types
302
362
  * @param {number} [opts.since] only conversations whose last activity is at or after this time (ms)
303
363
  */
304
- async function analyze({ dir = DEFAULT_DIR, workspaces = [], customKinds = {}, since = null } = {}) {
364
+ async function analyze({ dir: dirs = DEFAULT_DIRS, workspaces = [], customKinds = {}, since = null } = {}) {
365
+ dirs = [].concat(dirs);
366
+ // The folders that exist, for the report's header; all of them when none does, to say where it looked.
367
+ const dir = (dirs.filter((d) => fs.existsSync(d)).length ? dirs.filter((d) => fs.existsSync(d)) : dirs).join(' + ');
305
368
  const custom = Object.entries(customKinds).map(([re, label]) => [new RegExp(re, 'i'), label]);
306
369
  // A file git keeps in the repo is never build output, whatever its folder is called.
307
370
  const classify = (p, root, rel) => custom.find(([re]) => re.test(p))?.[1]
@@ -321,16 +384,18 @@ async function analyze({ dir = DEFAULT_DIR, workspaces = [], customKinds = {}, s
321
384
  const wsKeys = workspaces.map(key);
322
385
 
323
386
  try {
324
- const dbs = fs.existsSync(dir) ? fs.readdirSync(dir).filter((f) => /^[0-9a-f-]{36}\.db$/i.test(f)) : [];
325
- const titles = await conversationTitles(dir, tmp);
326
- for (const f of dbs) {
387
+ 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]) : []));
388
+ const titles = new Map();
389
+ // The editor's summaries also list CLI conversations, sometimes with just a preview: the first folder's title wins.
390
+ for (const d of dirs) for (const [k, v] of await conversationTitles(d, tmp)) if (!titles.has(k)) titles.set(k, v);
391
+ for (const [dir, f] of dbs) {
327
392
  // When it happened: the transcript's step times, else the database's last write.
328
393
  const id = f.replace(/\.db$/, '');
329
394
  const log = realUsage(dir, id);
330
395
  const end = log?.end ?? fs.statSync(path.join(dir, f)).mtimeMs;
331
396
  const start = log?.start ?? end;
332
- // Real usage only when the log has token counts; otherwise the session is shown with the estimate.
333
- const usage = log?.calls ? log : null;
397
+ // Real usage from the log when it has token counts; else from gen_metadata (below), else the estimate.
398
+ let usage = log?.calls ? log : null;
334
399
  if (since && end < since) { r.olderSkipped++; continue; }
335
400
  let db;
336
401
  try { db = await openDb(path.join(dir, f), tmp); } catch { r.unreadable++; continue; }
@@ -390,6 +455,8 @@ async function analyze({ dir = DEFAULT_DIR, workspaces = [], customKinds = {}, s
390
455
  files.add(key(file), { ...fields, convs: once(seenFiles, key(file)) }, { abs: file, rel, root, project, kind: k });
391
456
  });
392
457
  }
458
+ const metaBlobs = db.all('select data from gen_metadata order by idx').map(([blob]) => blob);
459
+ if (!usage) usage = usageFromMeta(metaBlobs, log, end);
393
460
  if (usage) {
394
461
  r.usage.conversations++;
395
462
  for (const k of ['calls', 'input', 'cache', 'output', 'errors']) r.usage[k] += usage[k];
@@ -404,9 +471,9 @@ async function analyze({ dir = DEFAULT_DIR, workspaces = [], customKinds = {}, s
404
471
  // Model of each call. gen_metadata usually has one row per call, in the transcript's order; when the
405
472
  // counts match, the n-th row is the n-th call. When they don't, pairing by position would mislabel
406
473
  // calls, so the model selected in the transcript is used instead, or the conversation's only model.
407
- const metaStrs = db.all('select data from gen_metadata order by idx').map(([blob]) => collect(blob));
474
+ const metaStrs = metaBlobs.map(collect);
408
475
  const metaRows = metaStrs.map(modelOf);
409
- const calls = log ? log.perCall : metaRows.map(() => null);
476
+ const calls = usage?.fromMeta ? usage.perCall : log ? log.perCall : metaRows.map(() => null);
410
477
  const aligned = metaRows.length === calls.length;
411
478
  const rowModels = new Set(metaRows.filter((m) => m.model).map(modelKey));
412
479
  const onlyModel = rowModels.size === 1 ? metaRows.find((m) => m.model) : null;
@@ -431,7 +498,7 @@ async function analyze({ dir = DEFAULT_DIR, workspaces = [], customKinds = {}, s
431
498
  const main = [...counts].sort((a, b) => b[1] - a[1])[0];
432
499
  const model = main ? models.get(main[0]) : null;
433
500
  r.sessions.push({
434
- id, title: titles.get(id) || null, start, end, steps: steps.length, tokens: convBytes / BYTES_PER_TOKEN, usage,
501
+ id, title: titles.get(id) || null, origin: originOf(dir), start, end, steps: steps.length, tokens: convBytes / BYTES_PER_TOKEN, usage,
435
502
  model: model ? { model: model.model, effort: model.effort } : null, models: counts.size,
436
503
  // For --debug-models: where each call's model came from, and for rows where no model was recognized,
437
504
  // the short strings that look like a model id (to spot a new format). Never prompts, paths or code.
@@ -453,8 +520,17 @@ async function analyze({ dir = DEFAULT_DIR, workspaces = [], customKinds = {}, s
453
520
  r.sessions.sort((a, b) => billed(b) - billed(a) || b.tokens - a.tokens);
454
521
  // Many steps but no recognizable tool call: agy probably changed its storage format.
455
522
  r.formatUnrecognized = r.steps > 50 && r.toolCallsFound === 0;
523
+ // Conversations and real usage per origin (CLI, editor), biggest first.
524
+ const origins = new Map();
525
+ for (const s of r.sessions) {
526
+ const o = origins.get(s.origin) || { origin: s.origin, conversations: 0, calls: 0, input: 0, cache: 0, output: 0 };
527
+ o.conversations++;
528
+ if (s.usage) for (const k of ['calls', 'input', 'cache', 'output']) o[k] += s.usage[k];
529
+ origins.set(s.origin, o);
530
+ }
456
531
  return {
457
532
  ...r,
533
+ origins: [...origins.values()].sort((a, b) => b.input + b.output - (a.input + a.output) || b.conversations - a.conversations),
458
534
  tools: tools.all(), kinds: kinds.all(), folders: folders.all(), files: files.all(), projects: projects.all(),
459
535
  commands: commands.all(),
460
536
  models: [...models.values()].sort((a, b) => b.input + b.output - (a.input + a.output) || b.calls - a.calls),
@@ -462,4 +538,4 @@ async function analyze({ dir = DEFAULT_DIR, workspaces = [], customKinds = {}, s
462
538
  };
463
539
  }
464
540
 
465
- module.exports = { analyze, DEFAULT_DIR, kind, NON_SOURCE, realUsage, dayKey, modelOf, selectedModel, repoFiles };
541
+ module.exports = { analyze, DEFAULT_DIR, DEFAULT_DIRS, kind, 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
  `,
@@ -121,7 +121,12 @@ Options:
121
121
  modelsUnknown: (n) => `"unknown": ${n} calls for which agy recorded no model, neither in gen_metadata nor as a model change in the transcript. If there are many, run npx agy-insights@latest --debug-models and paste the result into an issue on GitHub (it only contains counts).`,
122
122
  efforts: { minimal: 'Minimal', low: 'Low', medium: 'Medium', high: 'High', xhigh: 'Extra high', 'extra high': 'Extra high', max: 'Max' },
123
123
  sessionsTitle: 'Sessions (real usage)',
124
- sessionCols: ['Conversation', 'When', 'Model', 'Tokens', '%', 'With cache', 'Calls', 'Errors'],
124
+ sessionCols: ['Conversation', 'When', 'Origin', 'Model', 'Tokens', '%', 'With cache', 'Calls', 'Errors'],
125
+ origin: { cli: 'CLI', editor: 'Editor / VS Code', other: 'Other folder' },
126
+ originsTitle: 'By origin',
127
+ originsHint: 'Where each conversation was held. The editor and the VS Code extension keep the same history, so they are counted together.',
128
+ originCols: ['Origin', 'Conversations', 'Calls', 'Tokens', '%', 'Per call', 'With cache'],
129
+ originsText: ' By origin:',
125
130
  noConversations: (scope, dir) => `No conversations${scope ? ` for <code>${scope}</code>` : ''} in <code>${dir}</code>.`,
126
131
  tileReal: 'real tokens (input + output)',
127
132
  tileCache: (calls) => `including cache · ${plural(calls, 'call', 'calls')}`,
@@ -197,7 +202,7 @@ Opciones:
197
202
  --days <n> Solo conversaciones con actividad en los últimos n días
198
203
  --lang <en|es> Idioma de la salida (por defecto, el del sistema)
199
204
  --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})
205
+ --dir <carpeta> Otra carpeta de historial (por defecto, la del CLI y la del editor: ${dir})
201
206
  --config <fichero> Configuración (por defecto ~/.agy-insights.json si existe)
202
207
  --help Esta ayuda
203
208
  `,
@@ -297,7 +302,12 @@ Opciones:
297
302
  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
303
  efforts: { minimal: 'Mínimo', low: 'Bajo', medium: 'Medio', high: 'Alto', xhigh: 'Muy alto', 'extra high': 'Muy alto', max: 'Máximo' },
299
304
  sessionsTitle: 'Sesiones (consumo real)',
300
- sessionCols: ['Conversación', 'Cuándo', 'Modelo', 'Tokens', '%', 'Con caché', 'Llamadas', 'Errores'],
305
+ sessionCols: ['Conversación', 'Cuándo', 'Origen', 'Modelo', 'Tokens', '%', 'Con caché', 'Llamadas', 'Errores'],
306
+ origin: { cli: 'CLI', editor: 'Editor / VS Code', other: 'Otra carpeta' },
307
+ originsTitle: 'Por origen',
308
+ originsHint: 'Desde dónde se tuvo cada conversación. El editor y la extensión de VS Code guardan el mismo historial, así que se cuentan juntos.',
309
+ originCols: ['Origen', 'Conversaciones', 'Llamadas', 'Tokens', '%', 'Por llamada', 'Con caché'],
310
+ originsText: ' Por origen:',
301
311
  noConversations: (scope, dir) => `No hay conversaciones${scope ? ` de <code>${scope}</code>` : ''} en <code>${dir}</code>.`,
302
312
  tileReal: 'tokens reales (entrada + salida)',
303
313
  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.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",
3
+ "version": "0.9.0",
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",