agy-insights 0.3.0 → 0.4.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/lib/i18n.js ADDED
@@ -0,0 +1,293 @@
1
+ // User-facing text in English and Spanish. The language comes from --lang, the "lang" config key, or the
2
+ // system locale (Spanish if it starts with "es", else English). Modules read strings through `t`, which
3
+ // always points at the current language, so setLang() must run before any report is built.
4
+
5
+ const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
6
+
7
+ const en = {
8
+ locale: 'en-US',
9
+ help: (dir) => `Usage: agy-insights [options] (or npx agy-insights, or node agy-insights.js)
10
+
11
+ Analyzes your Antigravity CLI history and opens a report in the browser.
12
+
13
+ Options:
14
+ --project <folder> Only conversations that touched that project ("." = current folder)
15
+ --text Summary in the terminal instead of the HTML report
16
+ --paths With --text, show paths and the proposed exclusions
17
+ --map Print a project map (--project, or the current folder) for AGENTS.md
18
+ --out <file> Where to save the HTML report (default: the temp folder)
19
+ --no-open Don't open the browser
20
+ --lang <en|es> Language of the output (default: the system's)
21
+ --dir <folder> Another history folder (default ${dir})
22
+ --config <file> Configuration (default ~/.agy-insights.json if it exists)
23
+ --help This help
24
+ `,
25
+ missingValue: (opt) => `Missing value for ${opt}`,
26
+ unknownOption: (opt) => `Unknown option: ${opt}. See --help.`,
27
+ badLang: (v) => `Unsupported language: ${v}. Use en or es.`,
28
+ configNotFound: (f) => `Configuration file not found: ${f}`,
29
+ cannotRead: (f, msg) => `Cannot read ${f}: ${msg}`,
30
+ mapDone: (tokens, file) => `\n~${tokens} tokens on every request. Paste it into ${file} if you like it; agy-insights does not write it.`,
31
+ reportName: 'report',
32
+ done: (convs, found, out) => `${convs} conversations analyzed, ${found} findings.\nReport: ${out}`,
33
+
34
+ kinds: {
35
+ build: 'build output (dist, bin, obj…)', logs: 'logs, maps, minified', generated: 'generated code / migrations',
36
+ other: 'other', code: (ext) => `code ${ext}`,
37
+ },
38
+
39
+ fixes: {
40
+ denyTitle: 'Deny reading it (Antigravity permission)',
41
+ denyWhere: 'inside "permissions" → "deny"',
42
+ 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.',
43
+ ignoreTitle: 'Add to .antigravityignore',
44
+ ignoreWhere: 'at the end of the file (create it if missing)',
45
+ ignoreNote: 'Hides it from @ completion; it is not documented to stop the agent from reading it.',
46
+ ruleTitle: 'Add a rule to AGENTS.md',
47
+ ruleWhere: 'at the end of the file',
48
+ ruleNote: 'It is an instruction to the model, not a block. It is sent with every request: keep it short. AGENTS.md is usually in the team repo.',
49
+ },
50
+
51
+ findings: {
52
+ formatTitle: 'The history format is not recognized',
53
+ formatWhy: (steps) => `There are ${steps} steps but no recognizable tool call. agy has probably changed how it stores conversations.`,
54
+ formatSuggestion: 'The results below are not reliable. Update agy-insights (npx agy-insights@latest) or open an issue on GitHub.',
55
+ rereadsTitle: 'The agent re-reads what it already had',
56
+ rereadsWhy: (n) => `${plural(n, 'read repeats', 'reads repeat')} a chunk already read in the same session. That content enters the context twice.`,
57
+ rereadsSuggestion: 'It usually happens in long sessions or after editing. Shorter sessions, one per task, or /compact reduce it. Do not exclude these files: the agent needs them.',
58
+ rereads: (n) => plural(n, 're-read', 're-reads'),
59
+ reads: (n) => plural(n, 'read', 'reads'),
60
+ ruleFolder: (rel, kind) => `Don't read \`${rel}/\`: it is ${kind}; work with the source code.`,
61
+ kindPattern: (project, kind) => `${project} › everything of type "${kind}"`,
62
+ kindPatternDetail: 'pattern for any folder in the project',
63
+ ruleKind: (kind, patterns) => `Don't read ${kind} (${patterns}) unless the task requires it.`,
64
+ nonSourceTitle: 'Generated, build or dependency files are being read',
65
+ nonSourceWhy: 'Build output, generated code, dependencies, lockfiles and logs are rarely needed to understand or change the code, and they tend to be large.',
66
+ nonSourceSuggestion: 'Check whether any of these reads was necessary. If not, you can block them with an Antigravity permission.',
67
+ crossTitle: 'The same files enter many sessions',
68
+ crossWhy: (n, min) => `${plural(n, 'file was', 'files were')} read in ${min} or more different conversations.`,
69
+ crossSuggestion: 'If they are the core of the project, that is normal. If they are large and the agent only needs part of them, telling it which part to look at saves reads.',
70
+ sessions: (n) => plural(n, 'session', 'sessions'),
71
+ heavyTitle: 'Commands with very long output',
72
+ heavyWhy: 'The full output of each command enters the context and is resent on the following turns.',
73
+ heavySuggestion: "Ask it to run only what is needed (one test, one project) or to filter the output. If it always happens, a rule in the project's AGENTS.md reminds it every session.",
74
+ heavyDetail: (calls, avg) => `${plural(calls, 'time', 'times')} · ~${avg} tokens each`,
75
+ heavyExample: (cmd) => `- When running \`${cmd}\`, limit the output: only the affected test or project, or filter the relevant lines.`,
76
+ longTitle: 'Very long sessions',
77
+ longWhy: (n, min) => `${plural(n, 'conversation exceeds', 'conversations exceed')} ${min} tokens. In a long session, everything accumulated is resent on every turn.`,
78
+ longSuggestion: 'A new conversation per task, or /compact when you change topic.',
79
+ conversation: (id) => `conversation ${id}`,
80
+ steps: (n) => plural(n, 'step', 'steps'),
81
+ longUsage: (calls, real, cached) => ` · ${calls} model calls, ${real} real tokens (${cached} with cache)`,
82
+ },
83
+
84
+ map: {
85
+ title: '## Project map',
86
+ intro: '(Generated by agy-insights. To find something, start from the matching folder and search with grep.)',
87
+ files: (n) => (n === 1 ? 'file' : 'files'),
88
+ large: 'Large files: search with grep and read only the range you need, not the whole file.',
89
+ lines: (n) => `~${n} lines`,
90
+ dontRead: (list) => `Don't read (generated or dependencies): ${list}`,
91
+ },
92
+
93
+ report: {
94
+ applied: 'already applied',
95
+ in: 'In',
96
+ copy: 'Copy',
97
+ copied: 'Copied',
98
+ howToExclude: 'How to exclude it',
99
+ exampleRule: 'Example rule for AGENTS.md',
100
+ exampleHint: "Add it to the project's AGENTS.md only if this happens often: rules are sent with every request.",
101
+ cols: ['What', '~tokens', '%', 'Times', 'Sessions'],
102
+ sessionsTitle: 'Sessions (real usage)',
103
+ sessionCols: ['Conversation', 'Tokens', '%', 'With cache', 'Calls', 'Errors'],
104
+ noConversations: (scope, dir) => `No conversations${scope ? ` for <code>${scope}</code>` : ''} in <code>${dir}</code>.`,
105
+ tileReal: 'real tokens (input + output)',
106
+ tileCache: (calls) => `including cache · ${calls} calls`,
107
+ tileContext: '~tokens in context',
108
+ tileTop5: 'in the 5 largest sessions',
109
+ tileReads: 'file reads',
110
+ tileRereads: 're-reads of the same chunk',
111
+ happening: "What's happening",
112
+ happeningHint: 'This page changes nothing. Each proposal tells you which file it goes in and what to paste; you decide.',
113
+ nothing: 'No repeated or unnecessary context detected.',
114
+ details: 'Details',
115
+ byProject: 'By project', outsideGit: 'outside a git repository',
116
+ byKind: 'By file type', byFolder: 'By folder', files: 'Files', tools: 'Tools', commands: 'Commands',
117
+ title: 'Context usage in Antigravity',
118
+ project: 'Project', wholeHistory: 'Whole history',
119
+ meta: (convs, date) => `${convs} conversations · generated ${date}`,
120
+ footerReal: (missing) => `Real tokens: the ones Antigravity records in each conversation's log (subagents included)${missing ? `; ${missing} conversations have no log and are not counted` : ''}.`,
121
+ footerCache: 'It is unknown whether the quota counts cache the same as new input, so both figures are shown.',
122
+ footerEstimate: '~tokens = stored size of each step / 4: useful to compare files and folders with each other.',
123
+ footerPrivacy: 'The history was read from a copy and was not modified. This page contains paths from your projects: review it before sharing.',
124
+ // Text summary
125
+ history: 'History', projectLower: 'project',
126
+ summary: (convs, steps, tokens) => `${convs} conversations, ${steps} steps, ~${tokens} tokens in context (estimated)`,
127
+ realUsage: (real, input, output, cached, calls) => `Real usage: ${real} tokens (input ${input} + output ${output}), ${cached} including cache, in ${calls} model calls`,
128
+ noLog: (n) => ` (${n} conversations without a log)`,
129
+ topSessions: (n) => ` The ${n} sessions that use the most:`,
130
+ sessionLine: (id, calls, errors, cached) => `conversation ${id} ${calls} calls${errors ? `, ${errors} errors` : ''}, ${cached} with cache`,
131
+ top5: (pct, reads, rereads, rpct) => `The 5 largest sessions account for ${pct}. Reads: ${reads}, re-reads ${rereads} (${rpct})`,
132
+ pathHidden: '(path hidden, use --paths)',
133
+ byKindText: 'By file type:',
134
+ textFooter: 'Nothing was modified. Real usage = agy log; ~tokens = step size / 4 (estimated).',
135
+ },
136
+ };
137
+
138
+ const es = {
139
+ locale: 'es-ES',
140
+ help: (dir) => `Uso: agy-insights [opciones] (o npx agy-insights, o node agy-insights.js)
141
+
142
+ Analiza tu historial de Antigravity CLI y abre un informe en el navegador.
143
+
144
+ Opciones:
145
+ --project <carpeta> Solo conversaciones que tocaron ese proyecto ("." = carpeta actual)
146
+ --text Resumen en la terminal en vez del informe HTML
147
+ --paths Con --text, muestra rutas y las exclusiones propuestas
148
+ --map Imprime un mapa del proyecto (--project, o la carpeta actual) para AGENTS.md
149
+ --out <fichero> Dónde guardar el informe HTML (por defecto, en la carpeta temporal)
150
+ --no-open No abrir el navegador
151
+ --lang <en|es> Idioma de la salida (por defecto, el del sistema)
152
+ --dir <carpeta> Otra carpeta de historial (por defecto ${dir})
153
+ --config <fichero> Configuración (por defecto ~/.agy-insights.json si existe)
154
+ --help Esta ayuda
155
+ `,
156
+ missingValue: (opt) => `Falta el valor de ${opt}`,
157
+ unknownOption: (opt) => `Opción desconocida: ${opt}. Usa --help.`,
158
+ badLang: (v) => `Idioma no soportado: ${v}. Usa en o es.`,
159
+ configNotFound: (f) => `No existe el fichero de configuración ${f}`,
160
+ cannotRead: (f, msg) => `No se puede leer ${f}: ${msg}`,
161
+ mapDone: (tokens, file) => `\n~${tokens} tokens en cada petición. Pégalo en ${file} si te convence; agy-insights no lo escribe.`,
162
+ reportName: 'informe',
163
+ done: (convs, found, out) => `${convs} conversaciones analizadas, ${found} hallazgos.\nInforme: ${out}`,
164
+
165
+ kinds: {
166
+ build: 'build output (dist, bin, obj…)', logs: 'logs, maps, minificados', generated: 'código generado / migraciones',
167
+ other: 'otros', code: (ext) => `código ${ext}`,
168
+ },
169
+
170
+ fixes: {
171
+ denyTitle: 'Denegar la lectura (permiso de Antigravity)',
172
+ denyWhere: 'dentro de "permissions" → "deny"',
173
+ 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.',
174
+ ignoreTitle: 'Añadir a .antigravityignore',
175
+ ignoreWhere: 'al final del fichero (créalo si no existe)',
176
+ ignoreNote: 'Lo oculta del autocompletado con @; no está documentado que impida leerlo.',
177
+ ruleTitle: 'Añadir una regla a AGENTS.md',
178
+ ruleWhere: 'al final del fichero',
179
+ ruleNote: 'Es una indicación al modelo, no un bloqueo. Se envía en cada petición: mantenla corta. AGENTS.md suele estar en el repo del equipo.',
180
+ },
181
+
182
+ findings: {
183
+ formatTitle: 'No se reconoce el formato del historial',
184
+ formatWhy: (steps) => `Hay ${steps} pasos pero ninguna llamada a herramienta reconocible. Probablemente agy ha cambiado cómo guarda las conversaciones.`,
185
+ formatSuggestion: 'Los resultados de abajo no son fiables. Actualiza agy-insights (npx agy-insights@latest) o abre una incidencia en GitHub.',
186
+ rereadsTitle: 'El agente vuelve a leer lo que ya tenía',
187
+ rereadsWhy: (n) => `${plural(n, 'lectura repite', 'lecturas repiten')} un trozo que ya se había leído en la misma sesión. Ese contenido entra dos veces en el contexto.`,
188
+ rereadsSuggestion: 'Suele pasar en sesiones largas o después de editar. Sesiones más cortas por tarea, o /compact, lo reducen. No conviene excluir estos ficheros: el agente los necesita.',
189
+ rereads: (n) => plural(n, 'relectura', 'relecturas'),
190
+ reads: (n) => plural(n, 'lectura', 'lecturas'),
191
+ ruleFolder: (rel, kind) => `No leas \`${rel}/\`: es ${kind}; trabaja con el código fuente.`,
192
+ kindPattern: (project, kind) => `${project} › todo lo de tipo «${kind}»`,
193
+ kindPatternDetail: 'patrón para cualquier carpeta del proyecto',
194
+ ruleKind: (kind, patterns) => `No leas ${kind} (${patterns}) salvo que la tarea lo pida.`,
195
+ nonSourceTitle: 'Se leen ficheros generados, compilados o de dependencias',
196
+ nonSourceWhy: 'Salidas de build, código generado, dependencias, lockfiles y logs casi nunca hacen falta para entender o cambiar el código, y suelen ser grandes.',
197
+ nonSourceSuggestion: 'Revisa si alguna de estas lecturas era necesaria. Si no, puedes impedirlas con un permiso de Antigravity.',
198
+ crossTitle: 'Los mismos ficheros entran en muchas sesiones',
199
+ crossWhy: (n, min) => `${plural(n, 'fichero se ha leído', 'ficheros se han leído')} en ${min} o más conversaciones distintas.`,
200
+ crossSuggestion: 'Si son el núcleo del proyecto es normal. Si son grandes y el agente solo necesita una parte, decirle qué trozo mirar ahorra lecturas.',
201
+ sessions: (n) => plural(n, 'sesión', 'sesiones'),
202
+ heavyTitle: 'Comandos con salidas muy largas',
203
+ heavyWhy: 'La salida completa de cada comando entra en el contexto y se reenvía en los turnos siguientes.',
204
+ heavySuggestion: 'Pide ejecutar solo lo necesario (un test concreto, un proyecto concreto) o filtrar la salida. Si pasa siempre, una regla en el AGENTS.md del proyecto lo recuerda en cada sesión.',
205
+ heavyDetail: (calls, avg) => `${plural(calls, 'vez', 'veces')} · ~${avg} tokens cada una`,
206
+ heavyExample: (cmd) => `- Al ejecutar \`${cmd}\`, limita la salida: solo el test o proyecto afectado, o filtra las líneas relevantes.`,
207
+ longTitle: 'Sesiones muy largas',
208
+ 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.`,
209
+ longSuggestion: 'Una conversación nueva por tarea, o /compact cuando cambias de tema.',
210
+ conversation: (id) => `conversación ${id}`,
211
+ steps: (n) => plural(n, 'paso', 'pasos'),
212
+ longUsage: (calls, real, cached) => ` · ${calls} llamadas al modelo, ${real} tokens reales (${cached} con caché)`,
213
+ },
214
+
215
+ map: {
216
+ title: '## Mapa del proyecto',
217
+ intro: '(Generado por agy-insights. Para encontrar algo, empieza por la carpeta que corresponda y busca con grep.)',
218
+ files: (n) => (n === 1 ? 'fichero' : 'ficheros'),
219
+ large: 'Ficheros grandes: busca con grep y lee solo el rango que necesites, no el fichero entero.',
220
+ lines: (n) => `~${n} líneas`,
221
+ dontRead: (list) => `No leas (generado o dependencias): ${list}`,
222
+ },
223
+
224
+ report: {
225
+ applied: 'ya aplicado',
226
+ in: 'En',
227
+ copy: 'Copiar',
228
+ copied: 'Copiado',
229
+ howToExclude: 'Cómo excluirlo',
230
+ exampleRule: 'Regla de ejemplo para AGENTS.md',
231
+ exampleHint: 'Ponla en el AGENTS.md del proyecto solo si te pasa a menudo: las reglas se envían en cada petición.',
232
+ cols: ['Qué', '~tokens', '%', 'Veces', 'Sesiones'],
233
+ sessionsTitle: 'Sesiones (consumo real)',
234
+ sessionCols: ['Conversación', 'Tokens', '%', 'Con caché', 'Llamadas', 'Errores'],
235
+ noConversations: (scope, dir) => `No hay conversaciones${scope ? ` de <code>${scope}</code>` : ''} en <code>${dir}</code>.`,
236
+ tileReal: 'tokens reales (entrada + salida)',
237
+ tileCache: (calls) => `contando la caché · ${calls} llamadas`,
238
+ tileContext: '~tokens en contexto',
239
+ tileTop5: 'en las 5 sesiones más grandes',
240
+ tileReads: 'lecturas de ficheros',
241
+ tileRereads: 'relecturas del mismo trozo',
242
+ happening: 'Qué está pasando',
243
+ happeningHint: 'Esta página no cambia nada. Cada propuesta te dice en qué fichero va y qué pegar; decides tú.',
244
+ nothing: 'No se ha detectado contexto repetido ni innecesario.',
245
+ details: 'Detalle',
246
+ byProject: 'Por proyecto', outsideGit: 'fuera de un repositorio git',
247
+ byKind: 'Por tipo de fichero', byFolder: 'Por carpeta', files: 'Ficheros', tools: 'Herramientas', commands: 'Comandos',
248
+ title: 'Uso de contexto en Antigravity',
249
+ project: 'Proyecto', wholeHistory: 'Todo el historial',
250
+ meta: (convs, date) => `${convs} conversaciones · generado ${date}`,
251
+ footerReal: (missing) => `Tokens reales: los que Antigravity anota en el registro de cada conversación (incluidos subagentes)${missing ? `; ${missing} conversaciones no tienen registro y no cuentan` : ''}.`,
252
+ footerCache: 'No se sabe si la cuota cuenta la caché igual que la entrada nueva: por eso se muestran las dos cifras.',
253
+ footerEstimate: '~tokens = tamaño guardado de cada paso / 4: sirve para comparar ficheros y carpetas entre sí.',
254
+ footerPrivacy: 'El historial se ha leído desde una copia y no se ha modificado. Esta página contiene rutas de tus proyectos: no la compartas sin revisarla.',
255
+ history: 'Historial', projectLower: 'proyecto',
256
+ summary: (convs, steps, tokens) => `${convs} conversaciones, ${steps} pasos, ~${tokens} tokens en contexto (estimado)`,
257
+ realUsage: (real, input, output, cached, calls) => `Consumo real: ${real} tokens (entrada ${input} + salida ${output}), ${cached} contando la caché, en ${calls} llamadas al modelo`,
258
+ noLog: (n) => ` (${n} conversaciones sin registro)`,
259
+ topSessions: (n) => ` Las ${n} sesiones que más gastan:`,
260
+ sessionLine: (id, calls, errors, cached) => `conversación ${id} ${calls} llamadas${errors ? `, ${errors} errores` : ''}, ${cached} con caché`,
261
+ top5: (pct, reads, rereads, rpct) => `Las 5 sesiones más grandes suman el ${pct}. Lecturas: ${reads}, relecturas ${rereads} (${rpct})`,
262
+ pathHidden: '(ruta oculta, usa --paths)',
263
+ byKindText: 'Por tipo de fichero:',
264
+ textFooter: 'No se ha modificado nada. Consumo real = registro de agy; ~tokens = tamaño del paso / 4 (estimado).',
265
+ },
266
+ };
267
+
268
+ const LANGS = { en, es };
269
+ let current = 'en';
270
+
271
+ // "es", "es-ES", "es_ES.UTF-8"… → es; anything else → en.
272
+ function detect(env = process.env) {
273
+ const sys = env.LC_ALL || env.LC_MESSAGES || env.LANG || env.LANGUAGE || Intl.DateTimeFormat().resolvedOptions().locale || '';
274
+ return /^es\b|^es[-_]/i.test(sys) ? 'es' : 'en';
275
+ }
276
+
277
+ function setLang(lang) {
278
+ if (!LANGS[lang]) return false;
279
+ current = lang;
280
+ return true;
281
+ }
282
+
283
+ const t = new Proxy({}, { get: (_, k) => LANGS[current][k] });
284
+ const lang = () => current;
285
+ const num = (n) => Math.round(n).toLocaleString(t.locale);
286
+
287
+ // Built-in kinds are stable ids (see history.js kind()); custom kinds from the config are shown as written.
288
+ function kindLabel(k) {
289
+ if (k.startsWith('code ')) return t.kinds.code(k.slice(5));
290
+ return t.kinds[k] || k;
291
+ }
292
+
293
+ module.exports = { t, setLang, lang, detect, num, kindLabel, LANGS };
package/lib/map.js CHANGED
@@ -5,9 +5,10 @@ const fs = require('fs');
5
5
  const path = require('path');
6
6
  const { spawnSync } = require('child_process');
7
7
  const { kind, NON_SOURCE } = require('./history');
8
+ const { t, num } = require('./i18n');
8
9
 
9
- const START = '<!-- agy-insights:mapa:inicio -->';
10
- const END = '<!-- agy-insights:mapa:fin -->';
10
+ const START = '<!-- agy-insights:map:start -->';
11
+ const END = '<!-- agy-insights:map:end -->';
11
12
  const DEFAULTS = { largeLines: 500, maxLarge: 10, maxFolders: 15, maxNames: 6 };
12
13
  // Never walked when git is not available; with git, ignored files are skipped anyway.
13
14
  const SKIP_DIRS = new Set(['.git', 'node_modules', '.vs', '.vscode', '.idea', '.gemini', '.agents']);
@@ -83,18 +84,18 @@ function buildMap(root, opts = {}) {
83
84
  .sort((a, b) => b.reads - a.reads || b.lines - a.lines)
84
85
  .slice(0, o.maxLarge);
85
86
 
86
- const lines = ['## Mapa del proyecto', '', '(Generado por agy-insights. Para encontrar algo, empieza por la carpeta que corresponda y busca con grep.)', ''];
87
+ const lines = [t.map.title, '', t.map.intro, ''];
87
88
  for (const { folder, files, subdirs } of mainFolders(source, o.maxFolders)) {
88
89
  const names = subdirs.length ? `: ${subdirs.slice(0, o.maxNames).join(', ')}${subdirs.length > o.maxNames ? '…' : ''}` : '';
89
- lines.push(`- ${folder}${names} (${files} ${files === 1 ? 'fichero' : 'ficheros'})`);
90
+ lines.push(`- ${folder}${names} (${files} ${t.map.files(files)})`);
90
91
  }
91
92
  if (large.length) {
92
- lines.push('', 'Ficheros grandes: busca con grep y lee solo el rango que necesites, no el fichero entero.');
93
- for (const x of large) lines.push(`- ${x.file} (~${x.lines.toLocaleString('es-ES')} líneas)`);
93
+ lines.push('', t.map.large);
94
+ for (const x of large) lines.push(`- ${x.file} (${t.map.lines(num(x.lines))})`);
94
95
  }
95
96
  if (generated.size) {
96
97
  const top = [...generated].filter((g) => ![...generated].some((h) => h !== g && g.startsWith(h))).sort();
97
- lines.push('', `No leas (generado o dependencias): ${top.join(', ')}`);
98
+ lines.push('', t.map.dontRead(top.join(', ')));
98
99
  }
99
100
  const text = lines.join('\n');
100
101
  return { text, block: `${START}\n${text}\n${END}`, tokens: Math.round(text.length / BYTES_PER_TOKEN), files: all.length, large };
package/lib/report.js CHANGED
@@ -1,8 +1,10 @@
1
1
  // Renders the analysis as a self-contained HTML page (opened in the browser) or as plain text.
2
2
  // The page is static: its only interaction is copying text, so it cannot change anything on disk.
3
3
 
4
+ const { t, lang, num, kindLabel } = require('./i18n');
5
+
4
6
  const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
5
- const fmt = (n) => Math.round(n).toLocaleString('es-ES');
7
+ const fmt = num;
6
8
  const pct = (a, b) => (b ? `${Math.round((100 * a) / b)}%` : '–');
7
9
  const label = (x) => (x.project ? `${x.project} › ${x.rel}` : x.abs || x.key);
8
10
 
@@ -14,19 +16,19 @@ const withCache = (u) => u.input + u.cache + u.output;
14
16
 
15
17
  function fixBlock(f) {
16
18
  return `<div class="fix${f.applied ? ' done' : ''}">
17
- <div class="fix-head"><b>${esc(f.title)}</b>${f.applied ? '<span class="pill">ya aplicado</span>' : ''}</div>
18
- <p class="where">En <code>${esc(f.file)}</code>, ${esc(f.where)}:</p>
19
- <div class="code"><button class="copy" type="button">Copiar</button><pre>${esc(f.text)}</pre></div>
19
+ <div class="fix-head"><b>${esc(f.title)}</b>${f.applied ? `<span class="pill">${t.report.applied}</span>` : ''}</div>
20
+ <p class="where">${t.report.in} <code>${esc(f.file)}</code>, ${esc(f.where)}:</p>
21
+ <div class="code"><button class="copy" type="button">${t.report.copy}</button><pre>${esc(f.text)}</pre></div>
20
22
  <p class="hint">${esc(f.note)}</p>
21
23
  </div>`;
22
24
  }
23
25
 
24
26
  function item(it) {
25
27
  const extra = it.fixes.length
26
- ? `<details><summary>Cómo excluirlo</summary>${it.fixes.map(fixBlock).join('')}</details>`
28
+ ? `<details><summary>${t.report.howToExclude}</summary>${it.fixes.map(fixBlock).join('')}</details>`
27
29
  : it.example
28
- ? `<details><summary>Regla de ejemplo para AGENTS.md</summary><div class="fix"><div class="code"><button class="copy" type="button">Copiar</button><pre>${esc(it.example)}</pre></div>
29
- <p class="hint">Ponla en el AGENTS.md del proyecto solo si te pasa a menudo: las reglas se envían en cada petición.</p></div></details>`
30
+ ? `<details><summary>${t.report.exampleRule}</summary><div class="fix"><div class="code"><button class="copy" type="button">${t.report.copy}</button><pre>${esc(it.example)}</pre></div>
31
+ <p class="hint">${t.report.exampleHint}</p></div></details>`
30
32
  : '';
31
33
  return `<li><div class="row"><div class="what"><span class="label">${esc(it.label)}</span><span class="detail">${esc(it.detail)}</span></div>
32
34
  <span class="num">${it.tokens ? `~${fmt(it.tokens)}` : ''}</span></div>${extra}</li>`;
@@ -43,7 +45,7 @@ function finding(f, total) {
43
45
  function table(title, rows, total, name = label) {
44
46
  if (!rows.length) return '';
45
47
  const max = Math.max(...rows.map((r) => r.tokens), 1);
46
- return `<h3>${esc(title)}</h3><div class="scroll"><table><thead><tr><th>Qué</th><th class="num">~tokens</th><th class="num">%</th><th class="num">Veces</th><th class="num">Sesiones</th></tr></thead><tbody>
48
+ return `<h3>${esc(title)}</h3><div class="scroll"><table><thead><tr>${t.report.cols.map((c, i) => `<th${i ? ' class="num"' : ''}>${c}</th>`).join('')}</tr></thead><tbody>
47
49
  ${rows.slice(0, 15).map((r) => `<tr><td><div class="bar" style="--w:${((100 * r.tokens) / max).toFixed(1)}%"></div><span>${esc(name(r))}</span></td>
48
50
  <td class="num">${fmt(r.tokens)}</td><td class="num">${pct(r.tokens, total)}</td><td class="num">${r.calls}</td><td class="num">${r.convs || ''}</td></tr>`).join('')}
49
51
  </tbody></table></div>`;
@@ -53,7 +55,7 @@ function sessionsTable(r) {
53
55
  const rows = r.sessions.filter((s) => s.usage).slice(0, 15);
54
56
  if (!rows.length) return '';
55
57
  const max = Math.max(...rows.map((s) => billed(s.usage)), 1);
56
- return `<h3>Sesiones (consumo real)</h3><div class="scroll"><table><thead><tr><th>Conversación</th><th class="num">Tokens</th><th class="num">%</th><th class="num">Con caché</th><th class="num">Llamadas</th><th class="num">Errores</th></tr></thead><tbody>
58
+ return `<h3>${t.report.sessionsTitle}</h3><div class="scroll"><table><thead><tr>${t.report.sessionCols.map((c, i) => `<th${i ? ' class="num"' : ''}>${c}</th>`).join('')}</tr></thead><tbody>
57
59
  ${rows.map((s) => `<tr><td><div class="bar" style="--w:${((100 * billed(s.usage)) / max).toFixed(1)}%"></div><span>${esc(s.id.slice(0, 8))}</span></td>
58
60
  <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>
59
61
  <td class="num">${s.usage.calls}</td><td class="num">${s.usage.errors || ''}</td></tr>`).join('')}
@@ -64,29 +66,29 @@ function html(r, found, { scope }) {
64
66
  const top5 = r.sessions.slice(0, 5).reduce((s, x) => s + x.tokens, 0);
65
67
  const u = r.usage;
66
68
  const body = !r.conversations
67
- ? `<p>No hay conversaciones${scope ? ` de <code>${esc(scope)}</code>` : ''} en <code>${esc(r.dir)}</code>.</p>`
69
+ ? `<p>${t.report.noConversations(scope && esc(scope), esc(r.dir))}</p>`
68
70
  : `<div class="tiles">
69
- ${u.conversations ? `<div><b>${fmt(billed(u))}</b><span>tokens reales (entrada + salida)</span></div>
70
- <div><b>${fmt(withCache(u))}</b><span>contando la caché · ${u.calls} llamadas</span></div>` : ''}
71
- <div><b>${fmt(r.tokens)}</b><span>~tokens en contexto</span></div>
72
- <div><b>${pct(top5, r.tokens)}</b><span>en las 5 sesiones más grandes</span></div>
73
- <div><b>${r.reads}</b><span>lecturas de ficheros</span></div>
74
- <div><b>${pct(r.rereads, r.reads)}</b><span>relecturas del mismo trozo</span></div>
71
+ ${u.conversations ? `<div><b>${fmt(billed(u))}</b><span>${t.report.tileReal}</span></div>
72
+ <div><b>${fmt(withCache(u))}</b><span>${t.report.tileCache(u.calls)}</span></div>` : ''}
73
+ <div><b>${fmt(r.tokens)}</b><span>${t.report.tileContext}</span></div>
74
+ <div><b>${pct(top5, r.tokens)}</b><span>${t.report.tileTop5}</span></div>
75
+ <div><b>${r.reads}</b><span>${t.report.tileReads}</span></div>
76
+ <div><b>${pct(r.rereads, r.reads)}</b><span>${t.report.tileRereads}</span></div>
75
77
  </div>
76
- <h2>Qué está pasando</h2>
77
- <p class="hint">Esta página no cambia nada. Cada propuesta te dice en qué fichero va y qué pegar; decides tú.</p>
78
- ${found.length ? found.map((f) => finding(f, r.tokens)).join('') : '<p>No se ha detectado contexto repetido ni innecesario.</p>'}
79
- <details class="detail-all"><summary><h2>Detalle</h2></summary>
78
+ <h2>${t.report.happening}</h2>
79
+ <p class="hint">${t.report.happeningHint}</p>
80
+ ${found.length ? found.map((f) => finding(f, r.tokens)).join('') : `<p>${t.report.nothing}</p>`}
81
+ <details class="detail-all"><summary><h2>${t.report.details}</h2></summary>
80
82
  ${sessionsTable(r)}
81
- ${table('Por proyecto', r.projects, r.tokens, (x) => x.project || 'fuera de un repositorio git')}
82
- ${table('Por tipo de fichero', r.kinds, r.tokens, (x) => x.key)}
83
- ${table('Por carpeta', r.folders, r.tokens)}
84
- ${table('Ficheros', r.files, r.tokens)}
85
- ${table('Herramientas', r.tools, r.tokens, (x) => x.key)}
86
- ${table('Comandos', r.commands, r.tokens, (x) => x.key)}
83
+ ${table(t.report.byProject, r.projects, r.tokens, (x) => x.project || t.report.outsideGit)}
84
+ ${table(t.report.byKind, r.kinds, r.tokens, (x) => kindLabel(x.key))}
85
+ ${table(t.report.byFolder, r.folders, r.tokens)}
86
+ ${table(t.report.files, r.files, r.tokens)}
87
+ ${table(t.report.tools, r.tools, r.tokens, (x) => x.key)}
88
+ ${table(t.report.commands, r.commands, r.tokens, (x) => x.key)}
87
89
  </details>`;
88
90
 
89
- return `<!doctype html><html lang="es"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
91
+ return `<!doctype html><html lang="${lang()}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
90
92
  <title>agy-insights</title>
91
93
  <style>
92
94
  :root { --bg: #f7f8f7; --surface: #fff; --fg: #1d2421; --muted: #5d6963; --line: #dde3df; --accent: #1f7a5a; --accent-soft: #e3f1ea;
@@ -125,19 +127,19 @@ function html(r, found, { scope }) {
125
127
  .detail-all > summary { color: var(--fg); }
126
128
  footer { margin-top: 32px; font-size: 13px; color: var(--muted); }
127
129
  </style></head><body><main>
128
- <h1>Uso de contexto en Antigravity</h1>
129
- <p class="meta">${scope ? `Proyecto <code>${esc(scope)}</code>` : 'Todo el historial'} · ${r.conversations} conversaciones · generado ${esc(new Date().toLocaleString('es-ES'))}</p>
130
+ <h1>${t.report.title}</h1>
131
+ <p class="meta">${scope ? `${t.report.project} <code>${esc(scope)}</code>` : t.report.wholeHistory} · ${t.report.meta(r.conversations, esc(new Date().toLocaleString(t.locale)))}</p>
130
132
  ${body}
131
- <footer>Tokens reales: los que Antigravity anota en el registro de cada conversación (incluidos subagentes)${u.conversations < r.conversations ? `; ${r.conversations - u.conversations} conversaciones no tienen registro y no cuentan` : ''}.
132
- No se sabe si la cuota cuenta la caché igual que la entrada nueva: por eso se muestran las dos cifras.
133
- ~tokens = tamaño guardado de cada paso / 4: sirve para comparar ficheros y carpetas entre sí.
134
- El historial se ha leído desde una copia y no se ha modificado. Esta página contiene rutas de tus proyectos: no la compartas sin revisarla.</footer>
133
+ <footer>${esc(t.report.footerReal(r.conversations - u.conversations))}
134
+ ${esc(t.report.footerCache)}
135
+ ${esc(t.report.footerEstimate)}
136
+ ${esc(t.report.footerPrivacy)}</footer>
135
137
  </main>
136
138
  <script>
137
139
  document.addEventListener('click', (e) => {
138
140
  const b = e.target.closest('.copy'); if (!b) return;
139
141
  const pre = b.parentElement.querySelector('pre');
140
- const ok = () => { b.textContent = 'Copiado'; setTimeout(() => (b.textContent = 'Copiar'), 1500); };
142
+ const ok = () => { b.textContent = ${JSON.stringify(t.report.copied)}; setTimeout(() => (b.textContent = ${JSON.stringify(t.report.copy)}), 1500); };
141
143
  (navigator.clipboard ? navigator.clipboard.writeText(pre.textContent) : Promise.reject()).then(ok).catch(() => {
142
144
  const range = document.createRange(); range.selectNodeContents(pre);
143
145
  const sel = getSelection(); sel.removeAllRanges(); sel.addRange(range);
@@ -150,33 +152,33 @@ function html(r, found, { scope }) {
150
152
 
151
153
  function text(r, found, { scope, paths }) {
152
154
  const top5 = r.sessions.slice(0, 5).reduce((s, x) => s + x.tokens, 0);
153
- const out = [`\nHistorial: ${r.dir} (${r.backend})${scope ? ` · proyecto ${scope}` : ''}`,
154
- `${r.conversations} conversaciones, ${r.steps} pasos, ~${fmt(r.tokens)} tokens en contexto (estimado)`];
155
+ const out = [`\n${t.report.history}: ${r.dir} (${r.backend})${scope ? ` · ${t.report.projectLower} ${scope}` : ''}`,
156
+ t.report.summary(r.conversations, r.steps, fmt(r.tokens))];
155
157
  if (!r.conversations) return out.join('\n') + '\n';
156
158
  const u = r.usage;
157
159
  if (u.conversations) {
158
- out.push(`Consumo real: ${fmt(billed(u))} tokens (entrada ${fmt(u.input)} + salida ${fmt(u.output)}), ${fmt(withCache(u))} contando la caché, en ${u.calls} llamadas al modelo`
159
- + (u.conversations < r.conversations ? ` (${r.conversations - u.conversations} conversaciones sin registro)` : ''));
160
+ out.push(t.report.realUsage(fmt(billed(u)), fmt(u.input), fmt(u.output), fmt(withCache(u)), u.calls)
161
+ + (u.conversations < r.conversations ? t.report.noLog(r.conversations - u.conversations) : ''));
160
162
  const top = r.sessions.filter((s) => s.usage).slice(0, 5);
161
- out.push(` Las ${top.length} sesiones que más gastan:`);
163
+ out.push(t.report.topSessions(top.length));
162
164
  for (const s of top) {
163
- out.push(` ${fmt(billed(s.usage)).padStart(10)} ${pct(billed(s.usage), billed(u)).padStart(4)} conversación ${s.id.slice(0, 8)} ${s.usage.calls} llamadas${s.usage.errors ? `, ${s.usage.errors} errores` : ''}, ${fmt(withCache(s.usage))} con caché`);
165
+ out.push(` ${fmt(billed(s.usage)).padStart(10)} ${pct(billed(s.usage), billed(u)).padStart(4)} ${t.report.sessionLine(s.id.slice(0, 8), s.usage.calls, s.usage.errors, fmt(withCache(s.usage)))}`);
164
166
  }
165
167
  }
166
- out.push(`Las 5 sesiones más grandes suman el ${pct(top5, r.tokens)}. Lecturas: ${r.reads}, relecturas ${r.rereads} (${pct(r.rereads, r.reads)})`);
168
+ out.push(t.report.top5(pct(top5, r.tokens), r.reads, r.rereads, pct(r.rereads, r.reads)));
167
169
  for (const f of found) {
168
170
  out.push('', `${f.level === 'warn' ? '⚠' : '•'} ${f.title}${f.tokens ? ` (~${fmt(f.tokens)} tokens, ${pct(f.tokens, r.tokens)})` : ''}`,
169
171
  ` ${f.why}`, ` → ${f.suggestion}`);
170
172
  // Only file and folder labels are paths; commands and session ids are always shown.
171
173
  const hasPaths = ['rereads', 'non-source', 'cross-session'].includes(f.id);
172
174
  for (const it of f.items) {
173
- out.push(` ${(it.tokens ? `~${fmt(it.tokens)}` : '').padStart(10)} ${paths || !hasPaths ? it.label : '(ruta oculta, usa --paths)'} ${it.detail || ''}`);
175
+ out.push(` ${(it.tokens ? `~${fmt(it.tokens)}` : '').padStart(10)} ${paths || !hasPaths ? it.label : t.report.pathHidden} ${it.detail || ''}`);
174
176
  if (paths) for (const x of it.fixes.filter((x) => !x.applied)) out.push(`${' '.repeat(16)}${x.title}: ${x.text.split('\n').join(' ')} → ${x.file}`);
175
177
  }
176
178
  }
177
- out.push('', 'Por tipo de fichero:');
178
- for (const k of r.kinds.slice(0, 10)) out.push(` ${`~${fmt(k.tokens)}`.padStart(10)} ${String(k.calls).padStart(5)}× ${k.key}`);
179
- out.push('', 'No se ha modificado nada. Consumo real = registro de agy; ~tokens = tamaño del paso / 4 (estimado).', '');
179
+ out.push('', t.report.byKindText);
180
+ for (const k of r.kinds.slice(0, 10)) out.push(` ${`~${fmt(k.tokens)}`.padStart(10)} ${String(k.calls).padStart(5)}× ${kindLabel(k.key)}`);
181
+ out.push('', t.report.textFooter, '');
180
182
  return out.join('\n');
181
183
  }
182
184
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "agy-insights",
3
- "version": "0.3.0",
4
- "description": "Muestra qué contexto se envía a Antigravity de forma repetida o innecesaria, a partir del historial local de Antigravity CLI.",
3
+ "version": "0.4.0",
4
+ "description": "Measures Antigravity CLI token usage and shows which context is sent repeatedly or needlessly, in English or Spanish. Run: npx agy-insights",
5
5
  "license": "MIT",
6
6
  "keywords": [
7
7
  "antigravity",
@@ -9,7 +9,10 @@
9
9
  "gemini",
10
10
  "tokens",
11
11
  "context",
12
- "cli"
12
+ "cli",
13
+ "token-usage",
14
+ "optimization",
15
+ "i18n"
13
16
  ],
14
17
  "bin": {
15
18
  "agy-insights": "agy-insights.js"
@@ -19,7 +22,8 @@
19
22
  "lib/",
20
23
  "vendor/",
21
24
  "agy-insights.example.json",
22
- "docs/"
25
+ "docs/",
26
+ "README.es.md"
23
27
  ],
24
28
  "engines": {
25
29
  "node": ">=20"