agy-insights 0.3.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.es.md +172 -0
- package/README.md +95 -77
- package/agy-insights.example.json +2 -2
- package/agy-insights.js +51 -28
- package/docs/how-it-works.md +102 -0
- package/lib/findings.js +45 -61
- package/lib/fixes.js +10 -9
- package/lib/history.js +84 -18
- package/lib/i18n.js +357 -0
- package/lib/map.js +8 -7
- package/lib/report.js +224 -59
- package/package.json +8 -4
- package/docs/como-funciona.md +0 -88
|
@@ -0,0 +1,102 @@
|
|
|
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`: 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.
|
|
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
|
+
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.
|
|
41
|
+
|
|
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
|
+
|
|
44
|
+
`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.
|
|
45
|
+
|
|
46
|
+
### SQLite
|
|
47
|
+
|
|
48
|
+
- With Node 22.13 or later, `node:sqlite` is used, which also reads the `-wal` file.
|
|
49
|
+
- 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.
|
|
50
|
+
|
|
51
|
+
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`.
|
|
52
|
+
|
|
53
|
+
### Projects
|
|
54
|
+
|
|
55
|
+
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.
|
|
56
|
+
|
|
57
|
+
## Languages
|
|
58
|
+
|
|
59
|
+
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`).
|
|
60
|
+
|
|
61
|
+
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.
|
|
62
|
+
|
|
63
|
+
To add a message, add it to both tables. To add a language, add a table and teach `detect()` to pick it.
|
|
64
|
+
|
|
65
|
+
## Adding a file type
|
|
66
|
+
|
|
67
|
+
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.
|
|
68
|
+
|
|
69
|
+
## Adding a detector
|
|
70
|
+
|
|
71
|
+
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.
|
|
72
|
+
|
|
73
|
+
Add a case to `test/fake-history.js` and a test.
|
|
74
|
+
|
|
75
|
+
## Tests
|
|
76
|
+
|
|
77
|
+
```sh
|
|
78
|
+
npm test
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
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).
|
|
82
|
+
|
|
83
|
+
To generate a sample history and try the report by hand:
|
|
84
|
+
|
|
85
|
+
```sh
|
|
86
|
+
node test/fake-history.js test/tmp/history
|
|
87
|
+
node agy-insights.js --dir test/tmp/history
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## npm package
|
|
91
|
+
|
|
92
|
+
`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.
|
|
93
|
+
|
|
94
|
+
- **Try the package locally**: `npm pack`, then `npx --yes --package=./agy-insights-<version>.tgz agy-insights --help`.
|
|
95
|
+
- **From the repository**, unpublished: `npx --yes github:migohe14/agy-insights`. With no dependencies and no build step, npm installs it straight from git.
|
|
96
|
+
- **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).
|
|
97
|
+
|
|
98
|
+
## To do
|
|
99
|
+
|
|
100
|
+
- Confirm with `agy` that a `read_file` `deny` rule blocks the read.
|
|
101
|
+
- Test with real histories from several developers (Windows and macOS).
|
|
102
|
+
- 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
|
|
12
|
+
'build': ['bin/', 'obj/', 'dist/', 'build/', 'out/', 'target/', 'coverage/'],
|
|
12
13
|
'lockfiles': ['*.lock', 'package-lock.json', 'pnpm-lock.yaml', 'packages.lock.json'],
|
|
13
|
-
'logs
|
|
14
|
-
'
|
|
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
|
|
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((
|
|
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:
|
|
40
|
-
|
|
41
|
-
|
|
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((
|
|
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
|
|
52
|
-
const abs =
|
|
53
|
-
const e = byFolder.get(abs) || { abs, rel:
|
|
54
|
-
e.tokens +=
|
|
55
|
-
e.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} · ${
|
|
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,
|
|
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
|
|
69
|
-
if (!
|
|
70
|
-
const k = `${
|
|
71
|
-
const e = perProject.get(k) || { root:
|
|
72
|
-
e.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:
|
|
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,
|
|
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:
|
|
86
|
-
|
|
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((
|
|
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:
|
|
98
|
-
|
|
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((
|
|
103
|
-
label: where(
|
|
104
|
-
fixes: nonSourceKinds.has(
|
|
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 >=
|
|
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:
|
|
114
|
-
|
|
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:
|
|
119
|
-
example:
|
|
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) >=
|
|
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:
|
|
132
|
-
|
|
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:
|
|
137
|
-
detail:
|
|
138
|
-
?
|
|
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:
|
|
26
|
-
file: AGY_SETTINGS, where:
|
|
26
|
+
how: 'deny', title: t.fixes.denyTitle,
|
|
27
|
+
file: AGY_SETTINGS, where: t.fixes.denyWhere,
|
|
27
28
|
text: JSON.stringify(rule),
|
|
28
|
-
note:
|
|
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:
|
|
42
|
-
file, where:
|
|
42
|
+
how: 'ignore', title: t.fixes.ignoreTitle,
|
|
43
|
+
file, where: t.fixes.ignoreWhere,
|
|
43
44
|
text: missing.join('\n'),
|
|
44
|
-
note:
|
|
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:
|
|
57
|
-
file, where:
|
|
57
|
+
how: 'rule', title: t.fixes.ruleTitle,
|
|
58
|
+
file, where: t.fixes.ruleWhere,
|
|
58
59
|
text: line,
|
|
59
|
-
note:
|
|
60
|
+
note: t.fixes.ruleNote,
|
|
60
61
|
applied: present,
|
|
61
62
|
};
|
|
62
63
|
}
|
package/lib/history.js
CHANGED
|
@@ -10,7 +10,23 @@ const DEFAULT_DIR = path.join(os.homedir(), '.gemini', 'antigravity-cli', 'conve
|
|
|
10
10
|
const BYTES_PER_TOKEN = 4;
|
|
11
11
|
const TOOL_RE = /^[a-z][a-z0-9_]{2,40}$/;
|
|
12
12
|
const FILE_KEYS = ['AbsolutePath', 'TargetFile', 'FilePath', 'File', 'Path'];
|
|
13
|
-
|
|
13
|
+
// Model of a call, as agy names it in gen_metadata: "Gemini 3.8 Flash (High)" (name and effort level), or
|
|
14
|
+
// the id "gemini-3.8-flash-high" when the display name is missing.
|
|
15
|
+
// Whole strings only, so that prose in the system prompt that mentions a model is not taken for one.
|
|
16
|
+
const MODEL_NAME_RE = /^((?:Gemini|Claude|GPT)[\w .-]*?) \(([A-Za-z][A-Za-z ]{1,15})\)$/;
|
|
17
|
+
const MODEL_ID_RE = /^((?:gemini|claude|gpt)-[\w.-]*?)-(minimal|low|medium|high|xhigh|max)$/;
|
|
18
|
+
|
|
19
|
+
function modelOf(strs) {
|
|
20
|
+
for (const s of strs) {
|
|
21
|
+
const m = s.length < 200 && MODEL_NAME_RE.exec(s);
|
|
22
|
+
if (m) return { model: m[1].trim(), effort: m[2].trim() };
|
|
23
|
+
}
|
|
24
|
+
for (const s of strs) {
|
|
25
|
+
const m = s.length < 200 && MODEL_ID_RE.exec(s);
|
|
26
|
+
if (m) return { model: m[1], effort: m[2] };
|
|
27
|
+
}
|
|
28
|
+
return { model: null, effort: null };
|
|
29
|
+
}
|
|
14
30
|
|
|
15
31
|
// --- SQLite access: node:sqlite when the runtime has it (Node >= 22.13), sql.js (WASM) otherwise. ---
|
|
16
32
|
|
|
@@ -106,18 +122,18 @@ const norm = (p) => p.replace(/[\\/]+/g, '/');
|
|
|
106
122
|
const key = (p) => norm(p).toLowerCase().replace(/\/+$/, '');
|
|
107
123
|
|
|
108
124
|
// Types that are rarely needed in the context: their reads are reported as findings.
|
|
109
|
-
|
|
110
|
-
|
|
125
|
+
// Kinds are stable ids; i18n.kindLabel() turns them into text.
|
|
126
|
+
const NON_SOURCE = new Set(['node_modules', 'build', 'lockfiles', 'logs', 'generated']);
|
|
111
127
|
|
|
112
128
|
function kind(p) {
|
|
113
129
|
if (/(^|\/)node_modules\//.test(p)) return 'node_modules';
|
|
114
|
-
if (/(^|\/)(dist|build|out|target|coverage|\.next|bin|obj)\//i.test(p)) return 'build
|
|
130
|
+
if (/(^|\/)(dist|build|out|target|coverage|\.next|bin|obj)\//i.test(p)) return 'build';
|
|
115
131
|
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
|
|
132
|
+
if (/\.(log|map)$|\.min\.(js|css)$/.test(p)) return 'logs';
|
|
117
133
|
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 '
|
|
134
|
+
if (/\.designer\.cs$|\.g\.cs$|\.generated\.\w+$|(^|\/)(generated|Migrations)\//i.test(p)) return 'generated';
|
|
119
135
|
const ext = path.extname(p).toLowerCase();
|
|
120
|
-
return ext ? `
|
|
136
|
+
return ext ? `code ${ext}` : 'other';
|
|
121
137
|
}
|
|
122
138
|
|
|
123
139
|
// Project root of a file: an explicit workspace containing it, or else the nearest folder with a .git
|
|
@@ -154,6 +170,9 @@ function locate(file, workspaces, depth = 3) {
|
|
|
154
170
|
|
|
155
171
|
// --- Real usage ---
|
|
156
172
|
|
|
173
|
+
// Local calendar day of a time, as YYYY-MM-DD.
|
|
174
|
+
const dayKey = (ms) => { const d = new Date(ms); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; };
|
|
175
|
+
|
|
157
176
|
// What the model was actually billed for in a conversation, from the transcript agy keeps next to the
|
|
158
177
|
// history (<conversations>/../brain/<id>/.system_generated/logs/transcript.jsonl): one line per step,
|
|
159
178
|
// and model calls carry input, cache-read and output tokens. Subagents have their own conversation and
|
|
@@ -162,16 +181,28 @@ function realUsage(dir, id) {
|
|
|
162
181
|
const file = path.join(dir, '..', 'brain', id, '.system_generated', 'logs', 'transcript.jsonl');
|
|
163
182
|
let text;
|
|
164
183
|
try { text = fs.readFileSync(file, 'utf8'); } catch { return null; }
|
|
165
|
-
|
|
184
|
+
// `days`: the same figures per local day of each model call (calls without a time go under null).
|
|
185
|
+
// `perCall`: each call's figures, in order (they match gen_metadata's rows one to one).
|
|
186
|
+
const u = { calls: 0, input: 0, cache: 0, output: 0, errors: 0, start: null, end: null, days: new Map(), perCall: [] };
|
|
166
187
|
for (const line of text.split('\n')) {
|
|
167
188
|
let e;
|
|
168
189
|
try { e = JSON.parse(line); } catch { continue; }
|
|
190
|
+
const at = Date.parse(e.created_at);
|
|
191
|
+
if (at) { u.start = Math.min(u.start ?? at, at); u.end = Math.max(u.end ?? at, at); }
|
|
169
192
|
if (e.status === 'ERROR') u.errors++;
|
|
170
193
|
if (e.type !== 'PLANNER_RESPONSE') continue;
|
|
171
194
|
u.calls++;
|
|
172
195
|
u.input += Number(e.input_tokens) || 0;
|
|
173
196
|
u.cache += Number(e.cache_read_tokens) || 0;
|
|
174
197
|
u.output += Number(e.output_tokens) || 0;
|
|
198
|
+
u.perCall.push({ input: Number(e.input_tokens) || 0, cache: Number(e.cache_read_tokens) || 0, output: Number(e.output_tokens) || 0 });
|
|
199
|
+
const k = at ? dayKey(at) : null;
|
|
200
|
+
const d = u.days.get(k) || { calls: 0, input: 0, cache: 0, output: 0 };
|
|
201
|
+
d.calls++;
|
|
202
|
+
d.input += Number(e.input_tokens) || 0;
|
|
203
|
+
d.cache += Number(e.cache_read_tokens) || 0;
|
|
204
|
+
d.output += Number(e.output_tokens) || 0;
|
|
205
|
+
u.days.set(k, d);
|
|
175
206
|
}
|
|
176
207
|
return u;
|
|
177
208
|
}
|
|
@@ -194,23 +225,34 @@ class Counter {
|
|
|
194
225
|
* @param {string} [opts.dir] conversations directory
|
|
195
226
|
* @param {string[]} [opts.workspaces] only count conversations that touched these folders (all if empty)
|
|
196
227
|
* @param {Object<string,string>} [opts.customKinds] regex -> label, checked before the built-in types
|
|
228
|
+
* @param {number} [opts.since] only conversations whose last activity is at or after this time (ms)
|
|
197
229
|
*/
|
|
198
|
-
async function analyze({ dir = DEFAULT_DIR, workspaces = [], customKinds = {} } = {}) {
|
|
230
|
+
async function analyze({ dir = DEFAULT_DIR, workspaces = [], customKinds = {}, since = null } = {}) {
|
|
199
231
|
const custom = Object.entries(customKinds).map(([re, label]) => [new RegExp(re, 'i'), label]);
|
|
200
232
|
const classify = (p) => custom.find(([re]) => re.test(p))?.[1] || kind(p);
|
|
201
233
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'agy-insights-'));
|
|
202
234
|
const r = {
|
|
203
235
|
dir, backend: nodeSqlite ? 'node:sqlite' : 'sql.js', conversations: 0, unreadable: 0, steps: 0, tokens: 0,
|
|
236
|
+
since, period: null, olderSkipped: 0,
|
|
204
237
|
toolCallsFound: 0, reads: 0, rangedReads: 0, linesRead: 0, rereads: 0, rereadTokens: 0, sessions: [],
|
|
205
238
|
usage: { conversations: 0, calls: 0, input: 0, cache: 0, output: 0, errors: 0 },
|
|
206
239
|
};
|
|
207
240
|
const tools = new Counter(), kinds = new Counter(), folders = new Counter(), files = new Counter();
|
|
208
|
-
const commands = new Counter(),
|
|
241
|
+
const commands = new Counter(), projects = new Counter();
|
|
242
|
+
const models = new Map(); // "model|effort" -> calls and real tokens
|
|
243
|
+
const daily = new Map(); // YYYY-MM-DD -> real usage that day
|
|
244
|
+
const firstDay = since ? dayKey(since) : '';
|
|
209
245
|
const wsKeys = workspaces.map(key);
|
|
210
246
|
|
|
211
247
|
try {
|
|
212
248
|
const dbs = fs.existsSync(dir) ? fs.readdirSync(dir).filter((f) => /^[0-9a-f-]{36}\.db$/i.test(f)) : [];
|
|
213
249
|
for (const f of dbs) {
|
|
250
|
+
// When it happened: the transcript's step times, else the database's last write.
|
|
251
|
+
const id = f.replace(/\.db$/, '');
|
|
252
|
+
const usage = realUsage(dir, id);
|
|
253
|
+
const end = usage?.end ?? fs.statSync(path.join(dir, f)).mtimeMs;
|
|
254
|
+
const start = usage?.start ?? end;
|
|
255
|
+
if (since && end < since) { r.olderSkipped++; continue; }
|
|
214
256
|
let db;
|
|
215
257
|
try { db = await openDb(path.join(dir, f), tmp); } catch { r.unreadable++; continue; }
|
|
216
258
|
try {
|
|
@@ -223,6 +265,7 @@ async function analyze({ dir = DEFAULT_DIR, workspaces = [], customKinds = {} }
|
|
|
223
265
|
}
|
|
224
266
|
|
|
225
267
|
r.conversations++;
|
|
268
|
+
r.period = { from: Math.min(r.period?.from ?? start, start), to: Math.max(r.period?.to ?? end, end) };
|
|
226
269
|
let convBytes = 0;
|
|
227
270
|
const seenRanges = new Set(), seenFiles = new Set(), seenFolders = new Set(), seenKinds = new Set(), seenProjects = new Set();
|
|
228
271
|
const seenCalls = new Map(); // call id -> 1 if that call re-read a range already read, else 0
|
|
@@ -268,17 +311,38 @@ async function analyze({ dir = DEFAULT_DIR, workspaces = [], customKinds = {} }
|
|
|
268
311
|
files.add(key(file), { ...fields, convs: once(seenFiles, key(file)) }, { abs: file, rel, root, project, kind: k });
|
|
269
312
|
});
|
|
270
313
|
}
|
|
271
|
-
const id = f.replace(/\.db$/, '');
|
|
272
|
-
const usage = realUsage(dir, id);
|
|
273
314
|
if (usage) {
|
|
274
315
|
r.usage.conversations++;
|
|
275
316
|
for (const k of ['calls', 'input', 'cache', 'output', 'errors']) r.usage[k] += usage[k];
|
|
317
|
+
for (const [k, v] of usage.days) {
|
|
318
|
+
const day = k || dayKey(end);
|
|
319
|
+
if (day < firstDay) continue;
|
|
320
|
+
const d = daily.get(day) || { day, calls: 0, input: 0, cache: 0, output: 0 };
|
|
321
|
+
for (const f of ['calls', 'input', 'cache', 'output']) d[f] += v[f];
|
|
322
|
+
daily.set(day, d);
|
|
323
|
+
}
|
|
276
324
|
}
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
325
|
+
// One gen_metadata row per model call, in the transcript's order: the n-th row's model made the n-th call.
|
|
326
|
+
const callModels = db.all('select data from gen_metadata order by idx').map(([blob]) => modelOf(collect(blob)));
|
|
327
|
+
const n = Math.max(callModels.length, usage ? usage.perCall.length : 0);
|
|
328
|
+
const seenModels = new Set();
|
|
329
|
+
for (let i = 0; i < n; i++) {
|
|
330
|
+
const { model, effort } = callModels[i] || { model: null, effort: null };
|
|
331
|
+
const k = `${model}|${effort}`;
|
|
332
|
+
const e = models.get(k) || { model, effort, calls: 0, convs: 0, input: 0, cache: 0, output: 0, known: 0 };
|
|
333
|
+
e.calls++;
|
|
334
|
+
if (!seenModels.has(k)) { seenModels.add(k); e.convs++; }
|
|
335
|
+
const c = usage?.perCall[i];
|
|
336
|
+
if (c) { e.known++; e.input += c.input; e.cache += c.cache; e.output += c.output; }
|
|
337
|
+
models.set(k, e);
|
|
281
338
|
}
|
|
339
|
+
// The session's model: the one that made most of its calls.
|
|
340
|
+
const counts = new Map();
|
|
341
|
+
for (const m of callModels) counts.set(`${m.model}|${m.effort}`, (counts.get(`${m.model}|${m.effort}`) || 0) + 1);
|
|
342
|
+
const main = [...counts].sort((a, b) => b[1] - a[1])[0];
|
|
343
|
+
const model = main ? callModels.find((m) => `${m.model}|${m.effort}` === main[0]) : null;
|
|
344
|
+
r.sessions.push({ id, start, end, steps: steps.length, tokens: convBytes / BYTES_PER_TOKEN, usage, model: model?.model ? model : null, models: counts.size });
|
|
345
|
+
r.tokens += convBytes / BYTES_PER_TOKEN;
|
|
282
346
|
} catch { r.unreadable++; } finally { db.close(); }
|
|
283
347
|
}
|
|
284
348
|
} finally {
|
|
@@ -293,8 +357,10 @@ async function analyze({ dir = DEFAULT_DIR, workspaces = [], customKinds = {} }
|
|
|
293
357
|
return {
|
|
294
358
|
...r,
|
|
295
359
|
tools: tools.all(), kinds: kinds.all(), folders: folders.all(), files: files.all(), projects: projects.all(),
|
|
296
|
-
commands: commands.all(),
|
|
360
|
+
commands: commands.all(),
|
|
361
|
+
models: [...models.values()].sort((a, b) => b.input + b.output - (a.input + a.output) || b.calls - a.calls),
|
|
362
|
+
daily: [...daily.values()].sort((a, b) => (a.day < b.day ? -1 : 1)),
|
|
297
363
|
};
|
|
298
364
|
}
|
|
299
365
|
|
|
300
|
-
module.exports = { analyze, DEFAULT_DIR, kind, NON_SOURCE, realUsage };
|
|
366
|
+
module.exports = { analyze, DEFAULT_DIR, kind, NON_SOURCE, realUsage, dayKey, modelOf };
|