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.
@@ -0,0 +1,98 @@
1
+ # How it works
2
+
3
+ For whoever maintains or extends agy-insights.
4
+
5
+ ## Principles
6
+
7
+ Every change must respect them:
8
+
9
+ 1. **Read-only.** Nothing is written to `~/.gemini` or to the developer's projects. The history is copied to a temp folder before opening it, because `agy` keeps those databases open. The only thing written is the report.
10
+ 2. **The developer decides.** The tool proposes text to paste; it never applies it.
11
+ 3. **Only official Antigravity mechanisms** for exclusions: permissions (`read_file` in `permissions.deny`), `.antigravityignore` and rules in `AGENTS.md`. No hooks, plugins, `.gitignore` or `.ignore`.
12
+ 4. **Fail visibly.** If the history format changes, the report must say so instead of showing wrong data.
13
+ 5. **Nothing to install.** Only Node 20 or later; SQLite ships in `vendor/`.
14
+
15
+ ## Layout
16
+
17
+ ```
18
+ agy-insights.js command: options, configuration, writes the report and opens the browser
19
+ lib/history.js reads the history and aggregates reads, commands, projects and sessions
20
+ lib/findings.js turns the analysis into findings (the 5 detectors)
21
+ lib/fixes.js builds the text of each exclusion; writes nothing
22
+ lib/map.js project map for AGENTS.md (--map); writes nothing
23
+ lib/i18n.js every user-facing message, in English and Spanish, and language detection
24
+ lib/report.js HTML report (static, copy buttons only) and text summary
25
+ vendor/sql.js/ SQLite in WebAssembly (version in VERSION, MIT license in LICENSE)
26
+ test/fake-history.js generates fake histories in the same format as real ones
27
+ test/*.test.js tests (node --test)
28
+ ```
29
+
30
+ ## The Antigravity CLI history
31
+
32
+ It is undocumented; this is what has been observed in `agy` 1.4.x.
33
+
34
+ - One SQLite database per conversation: `~/.gemini/antigravity-cli/conversations/<uuid>.db`, in WAL mode.
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
+ - 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`: configuration of each model call, including the model name.
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
+
40
+ `lib/history.js` does not depend on a protobuf schema. It walks the message, collects every string, and treats as a tool call any JSON preceded (within 3 strings) by a `snake_case` name. That way it survives minor format changes. If there are more than 50 steps and no recognized call, it sets `formatUnrecognized` and the report warns about it.
41
+
42
+ ### SQLite
43
+
44
+ - With Node 22.13 or later, `node:sqlite` is used, which also reads the `-wal` file.
45
+ - With earlier versions, `sql.js` from `vendor/` is used. It doesn't read the `-wal`, so the last steps of an open conversation may be missing.
46
+
47
+ To update `sql.js`: copy `dist/sql-wasm.js`, `dist/sql-wasm.wasm` and `LICENSE` from the new version of the npm package and update `VERSION`.
48
+
49
+ ### Projects
50
+
51
+ The project of each file is detected by walking up its path until a folder with `.git` is found, if the path exists on the machine. With `--project`, that folder is used directly. Windows paths (`C:\...`) are normalized to `/` internally and returned with `\` in exclusions, because `agy` matches permission paths literally.
52
+
53
+ ## Languages
54
+
55
+ All user-facing text lives in `lib/i18n.js`, in an `en` and an `es` table with the same keys (a test checks it). Modules read it through `t`, which always points at the current language. The language comes from `--lang`, then the `lang` config key, then the system locale (`LC_ALL`, `LC_MESSAGES`, `LANG`, `LANGUAGE`, or `Intl`): Spanish if it starts with `es`, English otherwise. Numbers follow it too (`en-US` / `es-ES`).
56
+
57
+ Built-in file kinds are stable ids (`build`, `logs`, `generated`, `code .cs`…) and are only turned into text when shown, with `kindLabel()`. Kinds from `customKinds` are shown as the user wrote them.
58
+
59
+ To add a message, add it to both tables. To add a language, add a table and teach `detect()` to pick it.
60
+
61
+ ## Adding a file type
62
+
63
+ In `lib/history.js`, function `kind()`, returning a new id and its label in both tables of `lib/i18n.js`. If it is a type that rarely needs to be read, also add it to `NON_SOURCE`, and its `.antigravityignore` patterns to `KIND_PATTERNS` in `lib/findings.js`. Each developer can add their own types without touching the code with `customKinds` in their configuration.
64
+
65
+ ## Adding a detector
66
+
67
+ In `lib/findings.js`. A finding is `{ id, level: 'info' | 'warn', title, why, suggestion, tokens, items }`, and each `item` is `{ label, tokens, detail, fixes }`. `fixes` are built with `lib/fixes.js`. Never propose excluding source code: the agent needs it.
68
+
69
+ Add a case to `test/fake-history.js` and a test.
70
+
71
+ ## Tests
72
+
73
+ ```sh
74
+ npm test
75
+ ```
76
+
77
+ They cover the analysis with a fake history, the five detectors, the rule format, that excluding source code is never proposed, that the history is not changed, project detection through `.git`, the unknown-format warning, HTML escaping, the project map and the full command. CI runs them on Linux, Windows and macOS with Node 20 (sql.js) and Node 22 (node:sqlite).
78
+
79
+ To generate a sample history and try the report by hand:
80
+
81
+ ```sh
82
+ node test/fake-history.js test/tmp/history
83
+ node agy-insights.js --dir test/tmp/history
84
+ ```
85
+
86
+ ## npm package
87
+
88
+ `package.json` defines the command (`bin`) and which files go into the package (`files`: the command, `lib/`, `vendor/`, `docs/` and the sample configuration; not the tests). It is published on the public npm registry as `agy-insights`. The package only contains the tool; the histories it analyzes stay on each user's machine.
89
+
90
+ - **Try the package locally**: `npm pack`, then `npx --yes --package=./agy-insights-<version>.tgz agy-insights --help`.
91
+ - **From the repository**, unpublished: `npx --yes github:migohe14/agy-insights`. With no dependencies and no build step, npm installs it straight from git.
92
+ - **Release a version**: `npm version <patch|minor|major>` (updates `package.json` and creates the `v…` tag) and `git push --follow-tags`. The `.github/workflows/publish.yml` workflow runs the tests and `npm publish --provenance`. It needs the `NPM_TOKEN` secret in the GitHub repository (an npm *Automation* or *Granular* token with publish permission).
93
+
94
+ ## To do
95
+
96
+ - Confirm with `agy` that a `read_file` `deny` rule blocks the read.
97
+ - Test with real histories from several developers (Windows and macOS).
98
+ - Also read the history of the Antigravity app and IDE, if they use the same format.
package/lib/findings.js CHANGED
@@ -2,122 +2,108 @@
2
2
  // matters, and which official Antigravity mechanism could address it. Findings only describe and propose
3
3
  // (see fixes.js); applying anything is always the developer's decision.
4
4
  const fixes = require('./fixes');
5
+ const { t, num, kindLabel } = require('./i18n');
5
6
 
6
7
  const DEFAULTS = { crossSessionMin: 3, heavyOutputTokens: 2000, longSessionTokens: 100000 };
7
8
 
8
9
  // Suggested .antigravityignore patterns for the built-in non-source types.
9
10
  const KIND_PATTERNS = {
10
11
  'node_modules': ['node_modules/'],
11
- 'build output (dist, bin, obj…)': ['bin/', 'obj/', 'dist/', 'build/', 'out/', 'target/', 'coverage/'],
12
+ 'build': ['bin/', 'obj/', 'dist/', 'build/', 'out/', 'target/', 'coverage/'],
12
13
  'lockfiles': ['*.lock', 'package-lock.json', 'pnpm-lock.yaml', 'packages.lock.json'],
13
- 'logs, maps, minificados': ['*.log', '*.map', '*.min.js', '*.min.css'],
14
- 'código generado / migraciones': ['*.designer.cs', '*.g.cs', 'Migrations/'],
14
+ 'logs': ['*.log', '*.map', '*.min.js', '*.min.css'],
15
+ 'generated': ['*.designer.cs', '*.g.cs', 'Migrations/'],
15
16
  };
16
17
 
17
18
  const sum = (xs, f) => xs.reduce((s, x) => s + f(x), 0);
18
- const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
19
19
  const where = (x) => (x.project ? `${x.project} › ${x.rel}` : x.abs);
20
20
 
21
21
  function findings(r, opts = {}) {
22
- const t = { ...DEFAULTS, ...opts };
22
+ const o = { ...DEFAULTS, ...opts };
23
+ const f = t.findings;
23
24
  const nonSourceKinds = new Set(r.kinds.filter((k) => k.nonSource).map((k) => k.key));
24
25
  const out = [];
25
26
 
26
27
  if (r.formatUnrecognized) {
27
- out.push({
28
- id: 'format', level: 'warn', title: 'No se reconoce el formato del historial',
29
- why: `Hay ${r.steps} pasos pero ninguna llamada a herramienta reconocible. Probablemente agy ha cambiado cómo guarda las conversaciones.`,
30
- suggestion: 'Los resultados de abajo no son fiables. Actualiza agy-insights (git pull) o abre una incidencia en el repo.',
31
- items: [],
32
- });
28
+ out.push({ id: 'format', level: 'warn', title: f.formatTitle, why: f.formatWhy(r.steps), suggestion: f.formatSuggestion, items: [] });
33
29
  }
34
30
 
35
31
  // 1. Same chunk read again within a session: the content is paid twice. The file itself is usually needed.
36
- const reread = r.files.filter((f) => f.rereads > 0).sort((a, b) => b.rereadTokens - a.rereadTokens);
32
+ const reread = r.files.filter((x) => x.rereads > 0).sort((a, b) => b.rereadTokens - a.rereadTokens);
37
33
  if (reread.length) {
38
34
  out.push({
39
- id: 'rereads', level: 'info', title: 'El agente vuelve a leer lo que ya tenía',
40
- tokens: r.rereadTokens,
41
- why: `${plural(r.rereads, '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.`,
42
- suggestion: '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.',
43
- items: reread.slice(0, 10).map((f) => ({ label: where(f), tokens: f.rereadTokens, detail: plural(f.rereads, 'relectura', 'relecturas'), fixes: [] })),
35
+ id: 'rereads', level: 'info', title: f.rereadsTitle, tokens: r.rereadTokens,
36
+ why: f.rereadsWhy(r.rereads), suggestion: f.rereadsSuggestion,
37
+ items: reread.slice(0, 10).map((x) => ({ label: where(x), tokens: x.rereadTokens, detail: f.rereads(x.rereads), fixes: [] })),
44
38
  });
45
39
  }
46
40
 
47
41
  // 2. Generated, build output, vendor, lockfiles and logs: rarely needed, and candidates for exclusion.
48
- const nonSource = r.files.filter((f) => nonSourceKinds.has(f.kind));
42
+ const nonSource = r.files.filter((x) => nonSourceKinds.has(x.kind));
49
43
  if (nonSource.length) {
50
44
  const byFolder = new Map();
51
- for (const f of nonSource) {
52
- const abs = f.abs.slice(0, f.abs.lastIndexOf('/'));
53
- const e = byFolder.get(abs) || { abs, rel: f.rel.slice(0, f.rel.lastIndexOf('/')), root: f.root, project: f.project, kind: f.kind, tokens: 0, calls: 0 };
54
- e.tokens += f.tokens;
55
- e.calls += f.calls;
45
+ for (const x of nonSource) {
46
+ const abs = x.abs.slice(0, x.abs.lastIndexOf('/'));
47
+ const e = byFolder.get(abs) || { abs, rel: x.rel.slice(0, x.rel.lastIndexOf('/')), root: x.root, project: x.project, kind: x.kind, tokens: 0, calls: 0 };
48
+ e.tokens += x.tokens;
49
+ e.calls += x.calls;
56
50
  byFolder.set(abs, e);
57
51
  }
58
52
  const items = [...byFolder.values()].sort((a, b) => b.tokens - a.tokens).slice(0, 10).map((e) => ({
59
- label: `${where(e)}/`, tokens: e.tokens, detail: `${e.kind} · ${plural(e.calls, 'lectura', 'lecturas')}`,
53
+ label: `${where(e)}/`, tokens: e.tokens, detail: `${kindLabel(e.kind)} · ${f.reads(e.calls)}`,
60
54
  fixes: [
61
55
  fixes.deny(e.abs),
62
56
  e.root && fixes.ignore(e.root, [`${e.rel}/`]),
63
- e.root && fixes.rule(e.root, `No leas \`${e.rel}/\`: es ${e.kind}; trabaja con el código fuente.`),
57
+ e.root && fixes.rule(e.root, f.ruleFolder(e.rel, kindLabel(e.kind))),
64
58
  ].filter(Boolean),
65
59
  }));
66
60
  // One pattern-based item per project and type, for folders that have not been read yet.
67
61
  const perProject = new Map();
68
- for (const f of nonSource) {
69
- if (!f.root || !KIND_PATTERNS[f.kind]) continue;
70
- const k = `${f.root}|${f.kind}`;
71
- const e = perProject.get(k) || { root: f.root, project: f.project, kind: f.kind, tokens: 0 };
72
- e.tokens += f.tokens;
62
+ for (const x of nonSource) {
63
+ if (!x.root || !KIND_PATTERNS[x.kind]) continue;
64
+ const k = `${x.root}|${x.kind}`;
65
+ const e = perProject.get(k) || { root: x.root, project: x.project, kind: x.kind, tokens: 0 };
66
+ e.tokens += x.tokens;
73
67
  perProject.set(k, e);
74
68
  }
75
69
  for (const e of perProject.values()) {
76
70
  items.push({
77
- label: `${e.project} › todo lo de tipo «${e.kind}»`, tokens: e.tokens, detail: 'patrón para cualquier carpeta del proyecto',
71
+ label: f.kindPattern(e.project, kindLabel(e.kind)), tokens: e.tokens, detail: f.kindPatternDetail,
78
72
  fixes: [
79
73
  fixes.ignore(e.root, KIND_PATTERNS[e.kind]),
80
- fixes.rule(e.root, `No leas ${e.kind} (${KIND_PATTERNS[e.kind].join(', ')}) salvo que la tarea lo pida.`),
74
+ fixes.rule(e.root, f.ruleKind(kindLabel(e.kind), KIND_PATTERNS[e.kind].join(', '))),
81
75
  ],
82
76
  });
83
77
  }
84
78
  out.push({
85
- id: 'non-source', level: 'warn', title: 'Se leen ficheros generados, compilados o de dependencias',
86
- tokens: sum(nonSource, (f) => f.tokens),
87
- why: '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.',
88
- suggestion: 'Revisa si alguna de estas lecturas era necesaria. Si no, puedes impedirlas con un permiso de Antigravity.',
89
- items,
79
+ id: 'non-source', level: 'warn', title: f.nonSourceTitle, tokens: sum(nonSource, (x) => x.tokens),
80
+ why: f.nonSourceWhy, suggestion: f.nonSourceSuggestion, items,
90
81
  });
91
82
  }
92
83
 
93
84
  // 3. Same file entering many different sessions: repeated context across conversations.
94
- const cross = r.files.filter((f) => f.convs >= t.crossSessionMin).sort((a, b) => b.tokens - a.tokens);
85
+ const cross = r.files.filter((x) => x.convs >= o.crossSessionMin).sort((a, b) => b.tokens - a.tokens);
95
86
  if (cross.length) {
96
87
  out.push({
97
- id: 'cross-session', level: 'info', title: 'Los mismos ficheros entran en muchas sesiones',
98
- tokens: sum(cross, (f) => f.tokens),
99
- why: `${plural(cross.length, 'fichero se ha leído', 'ficheros se han leído')} en ${t.crossSessionMin} o más conversaciones distintas.`,
100
- suggestion: '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.',
88
+ id: 'cross-session', level: 'info', title: f.crossTitle, tokens: sum(cross, (x) => x.tokens),
89
+ why: f.crossWhy(cross.length, o.crossSessionMin), suggestion: f.crossSuggestion,
101
90
  // Exclusion is only offered for non-source files: denying core source code would break the agent's work.
102
- items: cross.slice(0, 10).map((f) => ({
103
- label: where(f), tokens: f.tokens, detail: `${plural(f.convs, 'sesión', 'sesiones')} · ${plural(f.calls, 'lectura', 'lecturas')}`,
104
- fixes: nonSourceKinds.has(f.kind) ? [fixes.deny(f.abs), f.root && fixes.ignore(f.root, [f.rel])].filter(Boolean) : [],
91
+ items: cross.slice(0, 10).map((x) => ({
92
+ label: where(x), tokens: x.tokens, detail: `${f.sessions(x.convs)} · ${f.reads(x.calls)}`,
93
+ fixes: nonSourceKinds.has(x.kind) ? [fixes.deny(x.abs), x.root && fixes.ignore(x.root, [x.rel])].filter(Boolean) : [],
105
94
  })),
106
95
  });
107
96
  }
108
97
 
109
98
  // 4. Commands whose output is large every time (test suites, builds).
110
- const heavy = r.commands.filter((c) => c.tokens / c.calls >= t.heavyOutputTokens).sort((a, b) => b.tokens - a.tokens);
99
+ const heavy = r.commands.filter((c) => c.tokens / c.calls >= o.heavyOutputTokens).sort((a, b) => b.tokens - a.tokens);
111
100
  if (heavy.length) {
112
101
  out.push({
113
- id: 'heavy-commands', level: 'warn', title: 'Comandos con salidas muy largas',
114
- tokens: sum(heavy, (c) => c.tokens),
115
- why: 'La salida completa de cada comando entra en el contexto y se reenvía en los turnos siguientes.',
116
- suggestion: '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.',
102
+ id: 'heavy-commands', level: 'warn', title: f.heavyTitle, tokens: sum(heavy, (c) => c.tokens),
103
+ why: f.heavyWhy, suggestion: f.heavySuggestion,
117
104
  items: heavy.slice(0, 8).map((c) => ({
118
- label: c.key, tokens: c.tokens, detail: `${plural(c.calls, 'vez', 'veces')} · ~${Math.round(c.tokens / c.calls).toLocaleString('es-ES')} tokens cada una`,
119
- example: `- Al ejecutar \`${c.key}\`, limita la salida: solo el test o proyecto afectado, o filtra las líneas relevantes.`,
120
- fixes: [],
105
+ label: c.key, tokens: c.tokens, detail: f.heavyDetail(c.calls, num(c.tokens / c.calls)),
106
+ example: f.heavyExample(c.key), fixes: [],
121
107
  })),
122
108
  });
123
109
  }
@@ -125,17 +111,15 @@ function findings(r, opts = {}) {
125
111
  // 5. Very long sessions: everything accumulated is resent on every turn.
126
112
  // Real usage when agy's transcript has it (it includes everything resent on each call), else the estimate.
127
113
  const size = (s) => (s.usage ? s.usage.input + s.usage.output : s.tokens);
128
- const long = r.sessions.filter((s) => size(s) >= t.longSessionTokens);
114
+ const long = r.sessions.filter((s) => size(s) >= o.longSessionTokens);
129
115
  if (long.length) {
130
116
  out.push({
131
- id: 'long-sessions', level: 'info', title: 'Sesiones muy largas',
132
- tokens: sum(long, (s) => s.tokens),
133
- why: `${plural(long.length, 'conversación supera', 'conversaciones superan')} ${t.longSessionTokens.toLocaleString('es-ES')} tokens. En una sesión larga, todo lo acumulado se vuelve a enviar en cada turno.`,
134
- suggestion: 'Una conversación nueva por tarea, o /compact cuando cambias de tema.',
117
+ id: 'long-sessions', level: 'info', title: f.longTitle, tokens: sum(long, (s) => s.tokens),
118
+ why: f.longWhy(long.length, num(o.longSessionTokens)), suggestion: f.longSuggestion,
135
119
  items: long.slice(0, 5).map((s) => ({
136
- label: `conversación ${s.id.slice(0, 8)}`, tokens: s.tokens, fixes: [],
137
- detail: plural(s.steps, 'paso', 'pasos') + (s.usage
138
- ? ` · ${s.usage.calls} llamadas al modelo, ${(s.usage.input + s.usage.output).toLocaleString('es-ES')} tokens reales (${(s.usage.input + s.usage.cache + s.usage.output).toLocaleString('es-ES')} con caché)`
120
+ label: f.conversation(s.id.slice(0, 8)), tokens: s.tokens, fixes: [],
121
+ detail: f.steps(s.steps) + (s.usage
122
+ ? f.longUsage(s.usage.calls, num(s.usage.input + s.usage.output), num(s.usage.input + s.usage.cache + s.usage.output))
139
123
  : ''),
140
124
  })),
141
125
  });
package/lib/fixes.js CHANGED
@@ -3,6 +3,7 @@
3
3
  const fs = require('fs');
4
4
  const os = require('os');
5
5
  const path = require('path');
6
+ const { t } = require('./i18n');
6
7
 
7
8
  const AGY_SETTINGS = path.join(os.homedir(), '.gemini', 'antigravity-cli', 'settings.json');
8
9
 
@@ -22,10 +23,10 @@ function deniedRules() {
22
23
  function deny(absPath) {
23
24
  const rule = `read_file(${native(absPath)})`;
24
25
  return {
25
- how: 'deny', title: 'Denegar la lectura (permiso de Antigravity)',
26
- file: AGY_SETTINGS, where: 'dentro de "permissions" → "deny"',
26
+ how: 'deny', title: t.fixes.denyTitle,
27
+ file: AGY_SETTINGS, where: t.fixes.denyWhere,
27
28
  text: JSON.stringify(rule),
28
- note: 'Es lo único que impide la lectura. Afecta a todos tus proyectos, también impide escribir en esa ruta, y hay que reiniciar agy.',
29
+ note: t.fixes.denyNote,
29
30
  applied: deniedRules().includes(rule),
30
31
  };
31
32
  }
@@ -38,10 +39,10 @@ function ignore(root, patterns) {
38
39
  try { current = fs.readFileSync(file, 'utf8').split(/\r?\n/).map((l) => l.trim()); } catch { /* not there yet */ }
39
40
  const missing = patterns.filter((p) => !current.includes(p));
40
41
  return {
41
- how: 'ignore', title: 'Añadir a .antigravityignore',
42
- file, where: 'al final del fichero (créalo si no existe)',
42
+ how: 'ignore', title: t.fixes.ignoreTitle,
43
+ file, where: t.fixes.ignoreWhere,
43
44
  text: missing.join('\n'),
44
- note: 'Lo oculta del autocompletado con @; no está documentado que impida leerlo.',
45
+ note: t.fixes.ignoreNote,
45
46
  applied: missing.length === 0,
46
47
  };
47
48
  }
@@ -53,10 +54,10 @@ function rule(root, text) {
53
54
  let present = false;
54
55
  try { present = fs.readFileSync(file, 'utf8').split(/\r?\n/).some((l) => l.trim() === line); } catch { /* none */ }
55
56
  return {
56
- how: 'rule', title: 'Añadir una regla a AGENTS.md',
57
- file, where: 'al final del fichero',
57
+ how: 'rule', title: t.fixes.ruleTitle,
58
+ file, where: t.fixes.ruleWhere,
58
59
  text: line,
59
- note: '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.',
60
+ note: t.fixes.ruleNote,
60
61
  applied: present,
61
62
  };
62
63
  }
package/lib/history.js CHANGED
@@ -106,18 +106,18 @@ const norm = (p) => p.replace(/[\\/]+/g, '/');
106
106
  const key = (p) => norm(p).toLowerCase().replace(/\/+$/, '');
107
107
 
108
108
  // Types that are rarely needed in the context: their reads are reported as findings.
109
- const NON_SOURCE = new Set(['node_modules', 'build output (dist, bin, obj…)', 'lockfiles',
110
- 'logs, maps, minificados', 'código generado / migraciones']);
109
+ // Kinds are stable ids; i18n.kindLabel() turns them into text.
110
+ const NON_SOURCE = new Set(['node_modules', 'build', 'lockfiles', 'logs', 'generated']);
111
111
 
112
112
  function kind(p) {
113
113
  if (/(^|\/)node_modules\//.test(p)) return 'node_modules';
114
- if (/(^|\/)(dist|build|out|target|coverage|\.next|bin|obj)\//i.test(p)) return 'build output (dist, bin, obj…)';
114
+ if (/(^|\/)(dist|build|out|target|coverage|\.next|bin|obj)\//i.test(p)) return 'build';
115
115
  if (/(package-lock\.json|yarn\.lock|pnpm-lock\.yaml|packages\.lock\.json|\.lock)$/.test(p)) return 'lockfiles';
116
- if (/\.(log|map)$|\.min\.(js|css)$/.test(p)) return 'logs, maps, minificados';
116
+ if (/\.(log|map)$|\.min\.(js|css)$/.test(p)) return 'logs';
117
117
  if (/(^|\/)(tests?|__tests__|specs?)\//i.test(p) || /\.(test|spec|tests?)\.\w+$/i.test(p) || /Tests?\//.test(p)) return 'tests';
118
- if (/\.designer\.cs$|\.g\.cs$|\.generated\.\w+$|(^|\/)(generated|Migrations)\//i.test(p)) return 'código generado / migraciones';
118
+ if (/\.designer\.cs$|\.g\.cs$|\.generated\.\w+$|(^|\/)(generated|Migrations)\//i.test(p)) return 'generated';
119
119
  const ext = path.extname(p).toLowerCase();
120
- return ext ? `código ${ext}` : 'otros';
120
+ return ext ? `code ${ext}` : 'other';
121
121
  }
122
122
 
123
123
  // Project root of a file: an explicit workspace containing it, or else the nearest folder with a .git