agy-insights 0.7.2 → 0.7.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.es.md CHANGED
@@ -36,7 +36,7 @@ npx agy-insights
36
36
  npx agy-insights@latest --text
37
37
  ```
38
38
 
39
- `@latest` hace que npx compruebe cada vez si hay una versión nueva. Sin él, una copia instalada globalmente (`npm install -g`) o en el proyecto actual tiene prioridad y no se actualiza nunca. Para fijar una versión concreta: `npx agy-insights@0.7.2`.
39
+ `@latest` hace que npx compruebe cada vez si hay una versión nueva. Sin él, una copia instalada globalmente (`npm install -g`) o en el proyecto actual tiene prioridad y no se actualiza nunca. Para fijar una versión concreta: `npx agy-insights@0.7.3`.
40
40
 
41
41
  **Instalándolo globalmente:**
42
42
 
@@ -161,6 +161,8 @@ Copia `agy-insights.example.json` a `~/.agy-insights.json` (o pásalo con `--con
161
161
 
162
162
  **Uso la app o el IDE de Antigravity, no el CLI**. Por ahora solo se lee el historial del CLI.
163
163
 
164
+ **Muchas llamadas con modelo «desconocido»**. agy no anotó su modelo. Ejecuta `npx agy-insights@latest --debug-models` y pega el resultado en una [incidencia](https://github.com/migohe14/agy-insights/issues): solo contiene recuentos y nombres de modelo.
165
+
164
166
  **El navegador no se abre**. La ruta del informe aparece en la terminal; ábrela a mano.
165
167
 
166
168
  ## Estado
package/README.md CHANGED
@@ -36,7 +36,7 @@ npx agy-insights
36
36
  npx agy-insights@latest --text
37
37
  ```
38
38
 
39
- `@latest` makes npx check for the newest version every time. Without it, a copy installed globally (`npm install -g`) or in the current project takes precedence and is never updated. To pin a version instead: `npx agy-insights@0.7.2`.
39
+ `@latest` makes npx check for the newest version every time. Without it, a copy installed globally (`npm install -g`) or in the current project takes precedence and is never updated. To pin a version instead: `npx agy-insights@0.7.3`.
40
40
 
41
41
  **Globally:**
42
42
 
@@ -161,6 +161,8 @@ Copy `agy-insights.example.json` to `~/.agy-insights.json` (or pass it with `--c
161
161
 
162
162
  **I use the Antigravity app or IDE, not the CLI**. For now only the CLI history is read.
163
163
 
164
+ **Many calls under model "unknown"**. agy recorded no model for them. Run `npx agy-insights@latest --debug-models` and paste the output into an [issue](https://github.com/migohe14/agy-insights/issues): it only has counts and model names.
165
+
164
166
  **The browser doesn't open**. The report path is printed in the terminal; open it manually.
165
167
 
166
168
  ## Status
package/agy-insights.js CHANGED
@@ -32,6 +32,7 @@ function parseArgs(argv) {
32
32
  else if (a === '--lang') opts.lang = value();
33
33
  else if (a === '--since') opts.since = value();
34
34
  else if (a === '--days') opts.days = value();
35
+ else if (a === '--debug-models') opts.debugModels = true;
35
36
  else fail(t.unknownOption(a));
36
37
  }
37
38
  return opts;
@@ -82,6 +83,20 @@ function sinceTime({ since, days }) {
82
83
  return null;
83
84
  }
84
85
 
86
+ // Tab-separated table of where each conversation's call models came from, for bug reports.
87
+ function debugModels(r) {
88
+ const d = t.debugModels;
89
+ const sum = { rows: 0, rowsWithModel: 0, calls: 0, fromTranscript: 0, unknown: 0 };
90
+ const lines = [d.header.join('\t')];
91
+ for (const s of r.sessions) {
92
+ const m = s.modelSources;
93
+ for (const k of Object.keys(sum)) sum[k] += m[k];
94
+ lines.push([s.id.slice(0, 8), m.calls, m.rows, m.rowsWithModel, m.fromTranscript, m.unknown, s.model ? `${s.model.model} (${s.model.effort})` : '', m.unknown ? m.candidates.join(' | ') : ''].join('\t'));
95
+ }
96
+ lines.push([d.total, sum.calls, sum.rows, sum.rowsWithModel, sum.fromTranscript, sum.unknown, '', ''].join('\t'), '', d.note, '');
97
+ return lines.join('\n');
98
+ }
99
+
85
100
  // Periods the HTML report can switch to, besides the whole history.
86
101
  const PRESET_DAYS = [90, 30, 7];
87
102
 
@@ -104,6 +119,7 @@ async function main() {
104
119
  customKinds: cfg.customKinds || {},
105
120
  });
106
121
  const r = await run(since);
122
+ if (opts.debugModels) return process.stdout.write(debugModels(r));
107
123
  if (opts.map) {
108
124
  const m = buildMap(opts.project, { ...cfg.map, history: r.files });
109
125
  console.log(m.block);
@@ -34,10 +34,10 @@ It is undocumented; this is what has been observed in `agy` 1.4.x.
34
34
  - One SQLite database per conversation: `~/.gemini/antigravity-cli/conversations/<uuid>.db`, in WAL mode.
35
35
  - Table `steps`: one row per step. `step_payload` is a protobuf message. Tool calls store the name (`view_file`, `run_command`, `grep_search`…) followed by their JSON arguments, for example `{"AbsolutePath":"C:\\dev\\...\\X.cs","StartLine":301,"EndLine":380}`, preceded by a `call_NNN` id.
36
36
  - Each call takes two steps with the same id: the model response that requests it (`step_type` 15) and the result (`step_type` 132), which repeats name and arguments and adds the file content or command output. `lib/history.js` counts the call once per id and adds the size of both steps, which approximates what entered the context.
37
- - Table `gen_metadata`: one row per model call, in the same order as the transcript's `PLANNER_RESPONSE` lines. Each row holds the model's display name with the effort level in brackets (`Gemini 3.8 Flash (High)`) and its id (`gemini-3.8-flash-high`). `modelOf()` takes the model and effort from whole strings only, so prose in the system prompt that mentions a model is ignored, and the n-th row is matched to the n-th call to get tokens per model and effort.
37
+ - Table `gen_metadata`: one row per model call, in the same order as the transcript's `PLANNER_RESPONSE` lines. Each row holds the model's display name with the effort level in brackets (`Gemini 3.8 Flash (High)`) and its id (`gemini-3.8-flash-high`). `modelOf()` takes the model and effort from whole strings only, so prose in the system prompt that mentions a model is ignored, and the n-th row is matched to the n-th call to get tokens per model and effort. That pairing is only trusted when the counts match. Otherwise each call takes the model selected in the transcript (`USER_INPUT` lines carry "changed setting `Model Selection` from X to Y"), or the conversation's only model; what is left is "unknown". `modelKey()` merges the two spellings (`Gemini 3.8 Flash` / `gemini-3.8-flash`, `High` / `high`). `--debug-models` prints, per conversation, how many calls got their model from each source: counts only, safe to paste into an issue.
38
38
  - Next to the history, `../brain/<id>/.system_generated/logs/transcript.jsonl`: one JSON line per step. `PLANNER_RESPONSE` lines are model calls and carry `input_tokens`, `cache_read_tokens` and `output_tokens`; failed steps have `status: "ERROR"`. Subagents have their own conversation and transcript. `realUsage()` in `lib/history.js` adds those figures up (it only reads the file). The total printed by `agy -p --output-format json` does not include subagents; this one does.
39
39
 
40
- Each `transcript.jsonl` line also has `created_at` (ISO time). A conversation is dated by the first and last of those; without a transcript, by the `.db` file's last write. `--since` / `--days` keep a conversation when its last activity falls inside the period, and skip the rest before opening them. `../conversation_summaries.db` also has `last_modified_time` per conversation, but it is not used. Each model call's `created_at` also puts its tokens on a local day (`r.daily`), which feeds the usage chart.
40
+ Each `transcript.jsonl` line also has `created_at` (ISO time). A conversation is dated by the first and last of those; without a transcript, by the `.db` file's last write. `--since` / `--days` keep a conversation when its last activity falls inside the period, and skip the rest before opening them. `../conversation_summaries.db` has one row per conversation: its short `title` (or `preview` in older ones) names each session in the report instead of its id; `last_modified_time` is not used. If the file is missing, sessions are shown by id. Each model call's `created_at` also puts its tokens on a local day (`r.daily`), which feeds the usage chart.
41
41
 
42
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
43
 
package/lib/findings.js CHANGED
@@ -131,7 +131,7 @@ function findings(r, opts = {}) {
131
131
  why: f.longWhy(long.length, num(o.longSessionTokens)), suggestion: f.longSuggestion,
132
132
  items: long.slice(0, 5).map((s) => ({
133
133
  // Real tokens when agy logged them (`exact`: shown without "~"), else the size estimate.
134
- label: f.conversation(s.id.slice(0, 8), when(s.start, s.end)), fixes: [],
134
+ label: f.conversation(s.id.slice(0, 8), when(s.start, s.end), s.title), fixes: [],
135
135
  tokens: s.usage ? s.usage.input + s.usage.output : s.tokens, exact: Boolean(s.usage),
136
136
  detail: f.steps(s.steps) + (s.usage
137
137
  ? f.longUsage(s.usage.calls, num(s.usage.input + s.usage.cache + s.usage.output))
package/lib/history.js CHANGED
@@ -29,6 +29,23 @@ function modelOf(strs) {
29
29
  return { model: null, effort: null };
30
30
  }
31
31
 
32
+ // agy notes model switches in the user's message: "changed setting `Model Selection` from None to
33
+ // Gemini 3.8 Flash (High)." The model in force for every call that follows.
34
+ const MODEL_SETTING_RE = /changed setting `Model Selection` from .+? to (.+?)\.(?:\s|$)/g;
35
+ function selectedModel(text) {
36
+ let last = null;
37
+ for (const m of String(text).matchAll(MODEL_SETTING_RE)) last = m[1].trim();
38
+ return last && modelOf([last]).model ? modelOf([last]) : null;
39
+ }
40
+
41
+ // A short string that may name a model in a format modelOf() does not know yet: mentions a model family,
42
+ // or is an enum-like id ("MODEL_PLACEHOLDER_M26"). Only used for --debug-models.
43
+ const looksLikeModel = (s) => s.length <= 60 && !/[\\/:]/.test(s)
44
+ && (/\b(gemini|claude|gpt|model|flash|pro|argon)\b/i.test(s) || /^[A-Z][A-Z0-9]*(_[A-Z0-9]+)+$/.test(s));
45
+
46
+ // "Gemini 3.8 Flash" and "gemini-3.8-flash", "High" and "high": the same model and effort.
47
+ const modelKey = (m) => `${(m.model || '').toLowerCase().replace(/[\s_]+/g, '-')}|${(m.effort || '').toLowerCase()}`;
48
+
32
49
  // --- SQLite access: node:sqlite when the runtime has it (Node >= 22.13), sql.js (WASM) otherwise. ---
33
50
 
34
51
  let nodeSqlite = null;
@@ -203,20 +220,27 @@ function realUsage(dir, id) {
203
220
  let text;
204
221
  try { text = fs.readFileSync(file, 'utf8'); } catch { return null; }
205
222
  // `days`: the same figures per local day of each model call (calls without a time go under null).
206
- // `perCall`: each call's figures, in order (they match gen_metadata's rows one to one).
223
+ // `perCall`: each call's figures, in order, and the model selected in the transcript when it was made.
207
224
  const u = { calls: 0, input: 0, cache: 0, output: 0, errors: 0, start: null, end: null, days: new Map(), perCall: [] };
225
+ let selected = null;
226
+ u.unrecognized = new Set();
208
227
  for (const line of text.split('\n')) {
209
228
  let e;
210
229
  try { e = JSON.parse(line); } catch { continue; }
211
230
  const at = Date.parse(e.created_at);
212
231
  if (at) { u.start = Math.min(u.start ?? at, at); u.end = Math.max(u.end ?? at, at); }
213
232
  if (e.status === 'ERROR') u.errors++;
233
+ if (e.type === 'USER_INPUT') {
234
+ selected = selectedModel(e.content) || selected;
235
+ // Model names agy selected that modelOf() does not understand, for --debug-models.
236
+ for (const m of String(e.content).matchAll(MODEL_SETTING_RE)) if (!modelOf([m[1].trim()]).model) u.unrecognized.add(m[1].trim().slice(0, 60));
237
+ }
214
238
  if (e.type !== 'PLANNER_RESPONSE') continue;
215
239
  u.calls++;
216
240
  u.input += Number(e.input_tokens) || 0;
217
241
  u.cache += Number(e.cache_read_tokens) || 0;
218
242
  u.output += Number(e.output_tokens) || 0;
219
- u.perCall.push({ input: Number(e.input_tokens) || 0, cache: Number(e.cache_read_tokens) || 0, output: Number(e.output_tokens) || 0 });
243
+ u.perCall.push({ input: Number(e.input_tokens) || 0, cache: Number(e.cache_read_tokens) || 0, output: Number(e.output_tokens) || 0, model: selected });
220
244
  const k = at ? dayKey(at) : null;
221
245
  const d = u.days.get(k) || { calls: 0, input: 0, cache: 0, output: 0 };
222
246
  d.calls++;
@@ -228,6 +252,26 @@ function realUsage(dir, id) {
228
252
  return u;
229
253
  }
230
254
 
255
+ // --- Titles ---
256
+
257
+ // The short title agy gives each conversation ("Analyzing Package Lock Dependencies"), from
258
+ // <conversations>/../conversation_summaries.db; older ones only have it as `preview`. Empty map when
259
+ // the file is missing or unreadable: the report then falls back to the conversation id.
260
+ async function conversationTitles(dir, tmp) {
261
+ const titles = new Map();
262
+ const file = path.join(dir, '..', 'conversation_summaries.db');
263
+ if (!fs.existsSync(file)) return titles;
264
+ let db;
265
+ try {
266
+ db = await openDb(file, tmp);
267
+ for (const [id, title, preview] of db.all('select conversation_id, title, preview from conversation_summaries')) {
268
+ const name = String(title || preview || '').trim().slice(0, 80);
269
+ if (name) titles.set(String(id), name);
270
+ }
271
+ } catch { /* no titles */ } finally { db?.close(); }
272
+ return titles;
273
+ }
274
+
231
275
  // --- Aggregation ---
232
276
 
233
277
  class Counter {
@@ -269,6 +313,7 @@ async function analyze({ dir = DEFAULT_DIR, workspaces = [], customKinds = {}, s
269
313
 
270
314
  try {
271
315
  const dbs = fs.existsSync(dir) ? fs.readdirSync(dir).filter((f) => /^[0-9a-f-]{36}\.db$/i.test(f)) : [];
316
+ const titles = await conversationTitles(dir, tmp);
272
317
  for (const f of dbs) {
273
318
  // When it happened: the transcript's step times, else the database's last write.
274
319
  const id = f.replace(/\.db$/, '');
@@ -345,26 +390,45 @@ async function analyze({ dir = DEFAULT_DIR, workspaces = [], customKinds = {}, s
345
390
  daily.set(day, d);
346
391
  }
347
392
  }
348
- // One gen_metadata row per model call, in the transcript's order: the n-th row's model made the n-th call.
349
- const callModels = db.all('select data from gen_metadata order by idx').map(([blob]) => modelOf(collect(blob)));
350
- const n = Math.max(callModels.length, usage ? usage.perCall.length : 0);
351
- const seenModels = new Set();
352
- for (let i = 0; i < n; i++) {
353
- const { model, effort } = callModels[i] || { model: null, effort: null };
354
- const k = `${model}|${effort}`;
355
- const e = models.get(k) || { model, effort, calls: 0, convs: 0, input: 0, cache: 0, output: 0, known: 0 };
393
+ // Model of each call. gen_metadata usually has one row per call, in the transcript's order; when the
394
+ // counts match, the n-th row is the n-th call. When they don't, pairing by position would mislabel
395
+ // calls, so the model selected in the transcript is used instead, or the conversation's only model.
396
+ const metaStrs = db.all('select data from gen_metadata order by idx').map(([blob]) => collect(blob));
397
+ const metaRows = metaStrs.map(modelOf);
398
+ const calls = usage ? usage.perCall : metaRows.map(() => null);
399
+ const aligned = metaRows.length === calls.length;
400
+ const rowModels = new Set(metaRows.filter((m) => m.model).map(modelKey));
401
+ const onlyModel = rowModels.size === 1 ? metaRows.find((m) => m.model) : null;
402
+ const callModels = calls.map((c, i) => {
403
+ if (aligned && metaRows[i].model) return metaRows[i];
404
+ return c?.model || onlyModel || { model: null, effort: null };
405
+ });
406
+ const seenModels = new Set(), counts = new Map();
407
+ callModels.forEach((m, i) => {
408
+ const k = modelKey(m);
409
+ const e = models.get(k) || { model: m.model, effort: m.effort, calls: 0, convs: 0, input: 0, cache: 0, output: 0, known: 0 };
410
+ // Prefer the display name ("Gemini 3.8 Flash", "High") over the id spelling.
411
+ if (m.model && /[A-Z ]/.test(m.model) && !/[A-Z ]/.test(e.model || '')) { e.model = m.model; e.effort = m.effort; }
356
412
  e.calls++;
357
413
  if (!seenModels.has(k)) { seenModels.add(k); e.convs++; }
358
- const c = usage?.perCall[i];
414
+ const c = calls[i];
359
415
  if (c) { e.known++; e.input += c.input; e.cache += c.cache; e.output += c.output; }
360
416
  models.set(k, e);
361
- }
417
+ if (m.model) counts.set(k, (counts.get(k) || 0) + 1);
418
+ });
362
419
  // The session's model: the one that made most of its calls.
363
- const counts = new Map();
364
- for (const m of callModels) counts.set(`${m.model}|${m.effort}`, (counts.get(`${m.model}|${m.effort}`) || 0) + 1);
365
420
  const main = [...counts].sort((a, b) => b[1] - a[1])[0];
366
- const model = main ? callModels.find((m) => `${m.model}|${m.effort}` === main[0]) : null;
367
- r.sessions.push({ id, start, end, steps: steps.length, tokens: convBytes / BYTES_PER_TOKEN, usage, model: model?.model ? model : null, models: counts.size });
421
+ const model = main ? models.get(main[0]) : null;
422
+ r.sessions.push({
423
+ id, title: titles.get(id) || null, start, end, steps: steps.length, tokens: convBytes / BYTES_PER_TOKEN, usage,
424
+ model: model ? { model: model.model, effort: model.effort } : null, models: counts.size,
425
+ // For --debug-models: where each call's model came from, and for rows where no model was recognized,
426
+ // the short strings that look like a model id (to spot a new format). Never prompts, paths or code.
427
+ modelSources: { rows: metaRows.length, rowsWithModel: metaRows.filter((m) => m.model).length, calls: calls.length,
428
+ fromTranscript: calls.filter((c) => c?.model).length, unknown: callModels.filter((m) => !m.model).length,
429
+ candidates: [...new Set([...(usage?.unrecognized || []),
430
+ ...metaStrs.filter((_, i) => !metaRows[i].model).flat().filter(looksLikeModel)])].slice(0, 10) },
431
+ });
368
432
  r.tokens += convBytes / BYTES_PER_TOKEN;
369
433
  } catch { r.unreadable++; } finally { db.close(); }
370
434
  }
@@ -386,4 +450,4 @@ async function analyze({ dir = DEFAULT_DIR, workspaces = [], customKinds = {}, s
386
450
  };
387
451
  }
388
452
 
389
- module.exports = { analyze, DEFAULT_DIR, kind, NON_SOURCE, realUsage, dayKey, modelOf, repoFiles };
453
+ module.exports = { analyze, DEFAULT_DIR, kind, NON_SOURCE, realUsage, dayKey, modelOf, selectedModel, repoFiles };
package/lib/i18n.js CHANGED
@@ -20,6 +20,7 @@ Options:
20
20
  --since <YYYY-MM-DD> Only conversations active since that date
21
21
  --days <n> Only conversations active in the last n days
22
22
  --lang <en|es> Language of the output (default: the system's)
23
+ --debug-models Per conversation, where each call's model came from (counts only, to report issues)
23
24
  --dir <folder> Another history folder (default ${dir})
24
25
  --config <file> Configuration (default ~/.agy-insights.json if it exists)
25
26
  --help This help
@@ -33,6 +34,11 @@ Options:
33
34
  cannotRead: (f, msg) => `Cannot read ${f}: ${msg}`,
34
35
  mapDone: (tokens, file) => `\n~${tokens} tokens on every request. Paste it into ${file} if you like it; agy-insights does not write it.`,
35
36
  reportName: 'report',
37
+ debugModels: {
38
+ header: ['conversation', 'calls', 'gen_metadata rows', 'rows with model', 'calls with model in transcript', 'unknown', 'main model', 'unrecognized model ids'],
39
+ total: 'total',
40
+ note: 'Only counts and model names or ids: no paths, prompts or code. Paste it into an issue if models show up as unknown.',
41
+ },
36
42
  done: (convs, found, out) => `${plural(convs, 'conversation', 'conversations')} analyzed, ${plural(found, 'finding', 'findings')}.\nReport: ${out}`,
37
43
 
38
44
  kinds: {
@@ -81,7 +87,7 @@ Options:
81
87
  longTitle: 'Very long sessions',
82
88
  longWhy: (n, min) => `${plural(n, 'conversation exceeds', 'conversations exceed')} ${min} tokens. In a long session, everything accumulated is resent on every turn.`,
83
89
  longSuggestion: 'A new conversation per task, or /compact when you change topic.',
84
- conversation: (id, at) => `conversation ${id}${at ? ` · ${at}` : ''}`,
90
+ conversation: (id, at, title) => `${title ? `"${title}" (${id})` : `conversation ${id}`}${at ? ` · ${at}` : ''}`,
85
91
  steps: (n) => plural(n, 'step', 'steps'),
86
92
  longUsage: (calls, cached) => ` · ${plural(calls, 'model call', 'model calls')} · real tokens, ${cached} with cache`,
87
93
  },
@@ -112,6 +118,7 @@ Options:
112
118
  modelLine: (model, effort, calls, tokens, pct, perCall) => `${model} · ${effort} ${plural(calls, 'call', 'calls')}, ${tokens} tokens (${pct}), ~${perCall} per call`,
113
119
  sessionModel: 'Model',
114
120
  otherModels: (n) => ` +${n} more`,
121
+ modelsUnknown: (n) => `"unknown": ${n} calls for which agy recorded no model, neither in gen_metadata nor as a model change in the transcript. If there are many, run npx agy-insights@latest --debug-models and paste the result into an issue on GitHub (it only contains counts).`,
115
122
  efforts: { minimal: 'Minimal', low: 'Low', medium: 'Medium', high: 'High', xhigh: 'Extra high', 'extra high': 'Extra high', max: 'Max' },
116
123
  sessionsTitle: 'Sessions (real usage)',
117
124
  sessionCols: ['Conversation', 'When', 'Model', 'Tokens', '%', 'With cache', 'Calls', 'Errors'],
@@ -158,7 +165,7 @@ Options:
158
165
  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
166
  noLog: (n) => ` (${plural(n, 'conversation', 'conversations')} without a log)`,
160
167
  topSessions: (n) => (n === 1 ? ' The session that uses the most:' : ` The ${n} sessions that use the most:`),
161
- sessionLine: (id, calls, errors, cached, model, at) => `conversation ${id}${at ? ` · ${at}` : ''}${model ? ` (${model})` : ''} ${plural(calls, 'call', 'calls')}${errors ? `, ${plural(errors, 'error', 'errors')}` : ''}, ${cached} with cache`,
168
+ sessionLine: (id, calls, errors, cached, model, at, title) => `${title ? `"${title}" (${id})` : `conversation ${id}`}${at ? ` · ${at}` : ''}${model ? ` (${model})` : ''} ${plural(calls, 'call', 'calls')}${errors ? `, ${plural(errors, 'error', 'errors')}` : ''}, ${cached} with cache`,
162
169
  top5: (pct, reads, rereads, rpct) => `The 5 largest sessions account for ${pct}. Reads: ${reads}, re-reads ${rereads} (${rpct})`,
163
170
  pathHidden: '(path hidden, use --paths)',
164
171
  byKindText: 'By file type:',
@@ -182,6 +189,7 @@ Opciones:
182
189
  --since <AAAA-MM-DD> Solo conversaciones con actividad desde esa fecha
183
190
  --days <n> Solo conversaciones con actividad en los últimos n días
184
191
  --lang <en|es> Idioma de la salida (por defecto, el del sistema)
192
+ --debug-models Por conversación, de dónde sale el modelo de cada llamada (solo recuentos, para reportar fallos)
185
193
  --dir <carpeta> Otra carpeta de historial (por defecto ${dir})
186
194
  --config <fichero> Configuración (por defecto ~/.agy-insights.json si existe)
187
195
  --help Esta ayuda
@@ -195,6 +203,11 @@ Opciones:
195
203
  cannotRead: (f, msg) => `No se puede leer ${f}: ${msg}`,
196
204
  mapDone: (tokens, file) => `\n~${tokens} tokens en cada petición. Pégalo en ${file} si te convence; agy-insights no lo escribe.`,
197
205
  reportName: 'informe',
206
+ debugModels: {
207
+ header: ['conversación', 'llamadas', 'filas gen_metadata', 'filas con modelo', 'llamadas con modelo en transcript', 'desconocidas', 'modelo principal', 'ids de modelo no reconocidos'],
208
+ total: 'total',
209
+ note: 'Solo recuentos y nombres o ids de modelo: ni rutas, ni prompts, ni código. Pégalo en una incidencia si salen modelos desconocidos.',
210
+ },
198
211
  done: (convs, found, out) => `${plural(convs, 'conversación analizada', 'conversaciones analizadas')}, ${plural(found, 'hallazgo', 'hallazgos')}.\nInforme: ${out}`,
199
212
 
200
213
  kinds: {
@@ -243,7 +256,7 @@ Opciones:
243
256
  longTitle: 'Sesiones muy largas',
244
257
  longWhy: (n, min) => `${plural(n, 'conversación supera', 'conversaciones superan')} ${min} tokens. En una sesión larga, todo lo acumulado se vuelve a enviar en cada turno.`,
245
258
  longSuggestion: 'Una conversación nueva por tarea, o /compact cuando cambias de tema.',
246
- conversation: (id, at) => `conversación ${id}${at ? ` · ${at}` : ''}`,
259
+ conversation: (id, at, title) => `${title ? `«${title}» (${id})` : `conversación ${id}`}${at ? ` · ${at}` : ''}`,
247
260
  steps: (n) => plural(n, 'paso', 'pasos'),
248
261
  longUsage: (calls, cached) => ` · ${plural(calls, 'llamada', 'llamadas')} al modelo · tokens reales, ${cached} con caché`,
249
262
  },
@@ -274,6 +287,7 @@ Opciones:
274
287
  modelLine: (model, effort, calls, tokens, pct, perCall) => `${model} · ${effort} ${plural(calls, 'llamada', 'llamadas')}, ${tokens} tokens (${pct}), ~${perCall} por llamada`,
275
288
  sessionModel: 'Modelo',
276
289
  otherModels: (n) => ` +${n} más`,
290
+ modelsUnknown: (n) => `«desconocido»: ${n} llamadas de las que agy no anotó el modelo, ni en gen_metadata ni como cambio de modelo en el transcript. Si son muchas, ejecuta npx agy-insights@latest --debug-models y pega el resultado en una incidencia de GitHub (solo contiene recuentos).`,
277
291
  efforts: { minimal: 'Mínimo', low: 'Bajo', medium: 'Medio', high: 'Alto', xhigh: 'Muy alto', 'extra high': 'Muy alto', max: 'Máximo' },
278
292
  sessionsTitle: 'Sesiones (consumo real)',
279
293
  sessionCols: ['Conversación', 'Cuándo', 'Modelo', 'Tokens', '%', 'Con caché', 'Llamadas', 'Errores'],
@@ -319,7 +333,7 @@ Opciones:
319
333
  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
334
  noLog: (n) => ` (${plural(n, 'conversación', 'conversaciones')} sin registro)`,
321
335
  topSessions: (n) => (n === 1 ? ' La sesión que más gasta:' : ` Las ${n} sesiones que más gastan:`),
322
- sessionLine: (id, calls, errors, cached, model, at) => `conversación ${id}${at ? ` · ${at}` : ''}${model ? ` (${model})` : ''} ${plural(calls, 'llamada', 'llamadas')}${errors ? `, ${plural(errors, 'error', 'errores')}` : ''}, ${cached} con caché`,
336
+ sessionLine: (id, calls, errors, cached, model, at, title) => `${title ? `«${title}» (${id})` : `conversación ${id}`}${at ? ` · ${at}` : ''}${model ? ` (${model})` : ''} ${plural(calls, 'llamada', 'llamadas')}${errors ? `, ${plural(errors, 'error', 'errores')}` : ''}, ${cached} con caché`,
323
337
  top5: (pct, reads, rereads, rpct) => `Las 5 sesiones más grandes suman el ${pct}. Lecturas: ${reads}, relecturas ${rereads} (${rpct})`,
324
338
  pathHidden: '(ruta oculta, usa --paths)',
325
339
  byKindText: 'Por tipo de fichero:',
package/lib/report.js CHANGED
@@ -60,7 +60,7 @@ function sessionsTable(r) {
60
60
  if (!rows.length) return '';
61
61
  const max = Math.max(...rows.map((s) => billed(s.usage)), 1);
62
62
  return `<h3>${t.report.sessionsTitle}</h3><div class="scroll"><table><thead><tr>${t.report.sessionCols.map((c, i) => `<th${i > 2 ? ' class="num"' : ''}>${c}</th>`).join('')}</tr></thead><tbody>
63
- ${rows.map((s) => `<tr><td><div class="bar" style="--w:${((100 * billed(s.usage)) / max).toFixed(1)}%"></div><span>${esc(s.id.slice(0, 8))}</span></td><td class="when">${esc(when(s.start, s.end))}</td><td>${esc(sessionModel(s))}</td>
63
+ ${rows.map((s) => `<tr><td><div class="bar" style="--w:${((100 * billed(s.usage)) / max).toFixed(1)}%"></div><span>${esc(s.title || s.id.slice(0, 8))}</span>${s.title ? `<span class="detail">${esc(s.id.slice(0, 8))}</span>` : ''}</td><td class="when">${esc(when(s.start, s.end))}</td><td>${esc(sessionModel(s))}</td>
64
64
  <td class="num">${fmt(billed(s.usage))}</td><td class="num">${pct(billed(s.usage), billed(r.usage))}</td><td class="num">${fmt(withCache(s.usage))}</td>
65
65
  <td class="num">${s.usage.calls}</td><td class="num">${s.usage.errors || ''}</td></tr>`).join('')}
66
66
  </tbody></table></div>`;
@@ -76,12 +76,13 @@ function modelsTable(r) {
76
76
  if (!rows.length || (rows.length === 1 && !rows[0].model)) return '';
77
77
  const total = rows.reduce((s, m) => s + m.input + m.output, 0);
78
78
  const max = Math.max(...rows.map((m) => m.input + m.output), 1);
79
+ const unknown = rows.find((m) => !m.model);
79
80
  return `<section class="models"><h2>${t.report.modelsTitle}</h2><p class="hint">${t.report.modelsHint}</p>
80
81
  <div class="scroll"><table><thead><tr>${t.report.modelCols.map((c, i) => `<th${i > 1 ? ' class="num"' : ''}>${c}</th>`).join('')}</tr></thead><tbody>
81
82
  ${rows.map((m) => { const tok = m.input + m.output; return `<tr><td><div class="bar" style="--w:${((100 * tok) / max).toFixed(1)}%"></div><span>${esc(modelName(m))}</span></td>
82
83
  <td>${esc(effortLabel(m.effort))}</td><td class="num">${fmt(m.calls)}</td><td class="num">${m.known ? fmt(tok) : '–'}</td><td class="num">${m.known ? pct(tok, total) : '–'}</td>
83
84
  <td class="num">${m.known ? fmt(tok / m.known) : '–'}</td><td class="num">${m.known ? fmt(tok + m.cache) : '–'}</td><td class="num">${m.convs}</td></tr>`; }).join('')}
84
- </tbody></table></div></section>`;
85
+ </tbody></table></div>${unknown ? `<p class="hint">${esc(t.report.modelsUnknown(fmt(unknown.calls)))}</p>` : ''}</section>`;
85
86
  }
86
87
 
87
88
  // --- Usage chart ---
@@ -316,7 +317,7 @@ function text(r, found, { scope, paths }) {
316
317
  const top = r.sessions.filter((s) => s.usage).slice(0, 5);
317
318
  out.push(t.report.topSessions(top.length));
318
319
  for (const s of top) {
319
- out.push(` ${fmt(billed(s.usage)).padStart(10)} ${pct(billed(s.usage), billed(u)).padStart(4)} ${t.report.sessionLine(s.id.slice(0, 8), s.usage.calls, s.usage.errors, fmt(withCache(s.usage)), sessionModel(s), when(s.start, s.end))}`);
320
+ out.push(` ${fmt(billed(s.usage)).padStart(10)} ${pct(billed(s.usage), billed(u)).padStart(4)} ${t.report.sessionLine(s.id.slice(0, 8), s.usage.calls, s.usage.errors, fmt(withCache(s.usage)), sessionModel(s), when(s.start, s.end), s.title)}`);
320
321
  }
321
322
  }
322
323
  out.push(t.report.top5(pct(top5, r.tokens), r.reads, r.rereads, pct(r.rereads, r.reads)));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agy-insights",
3
- "version": "0.7.2",
3
+ "version": "0.7.3",
4
4
  "description": "Measures Antigravity CLI token usage and shows which context is sent repeatedly or needlessly, in English or Spanish. Run: npx agy-insights@latest",
5
5
  "license": "MIT",
6
6
  "keywords": [