agy-insights 0.7.3 → 0.8.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 +3 -3
- package/README.md +3 -3
- package/agy-insights.js +11 -1
- package/docs/how-it-works.md +1 -1
- package/lib/i18n.js +18 -4
- package/lib/report.js +41 -14
- package/package.json +1 -1
package/README.es.md
CHANGED
|
@@ -36,7 +36,7 @@ npx agy-insights
|
|
|
36
36
|
npx agy-insights@latest --text
|
|
37
37
|
```
|
|
38
38
|
|
|
39
|
-
`@latest` hace que npx compruebe cada vez si hay una versión nueva. Sin él, una copia instalada globalmente (`npm install -g`) o en el proyecto actual tiene prioridad y no se actualiza nunca. Para fijar una versión concreta: `npx agy-insights@0.
|
|
39
|
+
`@latest` hace que npx compruebe cada vez si hay una versión nueva. Sin él, una copia instalada globalmente (`npm install -g`) o en el proyecto actual tiene prioridad y no se actualiza nunca. Para fijar una versión concreta: `npx agy-insights@0.8.0`.
|
|
40
40
|
|
|
41
41
|
**Instalándolo globalmente:**
|
|
42
42
|
|
|
@@ -61,7 +61,7 @@ Y ejecútalo con `node agy-insights/agy-insights.js` en lugar de `npx agy-insigh
|
|
|
61
61
|
npx agy-insights
|
|
62
62
|
```
|
|
63
63
|
|
|
64
|
-
Analiza todo tu historial (todas las conversaciones que guarde agy, sin límite de fecha), guarda un informe HTML en la carpeta temporal y lo abre en el navegador. En el informe puedes cambiar entre todo el historial y los últimos 90
|
|
64
|
+
Analiza todo tu historial (todas las conversaciones que guarde agy, sin límite de fecha), guarda un informe HTML en la carpeta temporal y lo abre en el navegador. En el informe puedes cambiar entre todo el historial y los últimos 90 o 30 días, o esta semana (desde el lunes): los totales, la gráfica, los hallazgos y las tablas siguen el periodo elegido.
|
|
65
65
|
|
|
66
66
|
| Para… | Ejecuta |
|
|
67
67
|
|---|---|
|
|
@@ -82,7 +82,7 @@ El informe da dos cifras:
|
|
|
82
82
|
|
|
83
83
|
## Leer el informe
|
|
84
84
|
|
|
85
|
-
Arriba están el selector de periodo y los totales. Después, una gráfica de **consumo de tokens por día** (por semana si el historial pasa de ~4 meses) para ver la evolución
|
|
85
|
+
Arriba están el selector de periodo y los totales. Después, una gráfica de **consumo de tokens por día** (por semana si el historial pasa de ~4 meses) para ver la evolución. Cámbiala a **Por llamada** para ver los tokens medios de cada llamada al modelo: el total sube y baja según cuánto hayas trabajado, mientras que por llamada solo baja si cada llamada lleva menos contexto, así que es la que muestra si un cambio ha servido. Pasa el ratón por una columna para ver sus cifras, o abre «Ver como tabla». Después, **Modelos y esfuerzo**: llamadas y tokens reales por modelo y nivel de esfuerzo (por ejemplo Gemini 3.8 Flash · Alto), con los tokens por llamada, para ver cuánto cuesta un esfuerzo mayor. Debajo, **Qué está pasando** reúne los hallazgos; al final, el detalle por proyecto, tipo de fichero, carpeta, fichero, herramienta y comando. El proyecto de cada fichero se detecta por su carpeta `.git`.
|
|
86
86
|
|
|
87
87
|
| Hallazgo | Qué significa | Qué hacer |
|
|
88
88
|
|---|---|---|
|
package/README.md
CHANGED
|
@@ -36,7 +36,7 @@ npx agy-insights
|
|
|
36
36
|
npx agy-insights@latest --text
|
|
37
37
|
```
|
|
38
38
|
|
|
39
|
-
`@latest` makes npx check for the newest version every time. Without it, a copy installed globally (`npm install -g`) or in the current project takes precedence and is never updated. To pin a version instead: `npx agy-insights@0.
|
|
39
|
+
`@latest` makes npx check for the newest version every time. Without it, a copy installed globally (`npm install -g`) or in the current project takes precedence and is never updated. To pin a version instead: `npx agy-insights@0.8.0`.
|
|
40
40
|
|
|
41
41
|
**Globally:**
|
|
42
42
|
|
|
@@ -61,7 +61,7 @@ Then run it with `node agy-insights/agy-insights.js` instead of `npx agy-insight
|
|
|
61
61
|
npx agy-insights
|
|
62
62
|
```
|
|
63
63
|
|
|
64
|
-
Analyzes your whole history (every conversation agy has kept, with no date limit), saves an HTML report in the temp folder and opens it in the browser. In the report you can switch between the whole history and the last 90
|
|
64
|
+
Analyzes your whole history (every conversation agy has kept, with no date limit), saves an HTML report in the temp folder and opens it in the browser. In the report you can switch between the whole history and the last 90 or 30 days, or this week (since Monday): totals, chart, findings and tables all follow the selected period.
|
|
65
65
|
|
|
66
66
|
| To… | Run |
|
|
67
67
|
|---|---|
|
|
@@ -82,7 +82,7 @@ The report gives two figures:
|
|
|
82
82
|
|
|
83
83
|
## Reading the report
|
|
84
84
|
|
|
85
|
-
At the top, the period selector and the totals. Then a chart of **token usage per day** (per week when the history spans more than ~4 months), to see how usage evolves
|
|
85
|
+
At the top, the period selector and the totals. Then a chart of **token usage per day** (per week when the history spans more than ~4 months), to see how usage evolves. Switch it to **Per call** to see mean tokens per model call: the total follows how much you worked, while per call drops only when each call carries less context, so it is the one that shows whether a change paid off. Hover a column for its figures, or open "View as table". Next, **Models and effort**: calls and real tokens per model and effort level (for example Gemini 3.8 Flash · High), including tokens per call, so you can see what a higher effort costs. Below, **What's happening** lists the findings; at the end, the details by project, file type, folder, file, tool and command. The project of each file is detected through its `.git` folder.
|
|
86
86
|
|
|
87
87
|
| Finding | What it means | What to do |
|
|
88
88
|
|---|---|---|
|
package/agy-insights.js
CHANGED
|
@@ -98,7 +98,15 @@ function debugModels(r) {
|
|
|
98
98
|
}
|
|
99
99
|
|
|
100
100
|
// Periods the HTML report can switch to, besides the whole history.
|
|
101
|
-
const PRESET_DAYS = [90, 30
|
|
101
|
+
const PRESET_DAYS = [90, 30];
|
|
102
|
+
|
|
103
|
+
// This Monday at local midnight.
|
|
104
|
+
function weekStart() {
|
|
105
|
+
const d = new Date();
|
|
106
|
+
d.setHours(0, 0, 0, 0);
|
|
107
|
+
d.setDate(d.getDate() - ((d.getDay() + 6) % 7));
|
|
108
|
+
return d.getTime();
|
|
109
|
+
}
|
|
102
110
|
|
|
103
111
|
async function main() {
|
|
104
112
|
const argv = process.argv.slice(2);
|
|
@@ -144,6 +152,8 @@ async function main() {
|
|
|
144
152
|
const x = await run(Date.now() - days * 24 * 60 * 60 * 1000);
|
|
145
153
|
views.push({ key: `d${days}`, label: t.report.rangeDays(days), r: x, found: find(x) });
|
|
146
154
|
}
|
|
155
|
+
const week = await run(weekStart());
|
|
156
|
+
views.push({ key: 'week', label: t.report.rangeWeek, r: week, found: find(week) });
|
|
147
157
|
}
|
|
148
158
|
fs.writeFileSync(out, report.htmlViews(views, { scope: opts.project }));
|
|
149
159
|
console.log(t.done(r.conversations, found.length, out));
|
package/docs/how-it-works.md
CHANGED
|
@@ -39,7 +39,7 @@ It is undocumented; this is what has been observed in `agy` 1.4.x.
|
|
|
39
39
|
|
|
40
40
|
Each `transcript.jsonl` line also has `created_at` (ISO time). A conversation is dated by the first and last of those; without a transcript, by the `.db` file's last write. `--since` / `--days` keep a conversation when its last activity falls inside the period, and skip the rest before opening them. `../conversation_summaries.db` has one row per conversation: its short `title` (or `preview` in older ones) names each session in the report instead of its id; `last_modified_time` is not used. If the file is missing, sessions are shown by id. Each model call's `created_at` also puts its tokens on a local day (`r.daily`), which feeds the usage chart.
|
|
41
41
|
|
|
42
|
-
The HTML report holds one view per period (whole history, last 90
|
|
42
|
+
The HTML report holds one view per period (whole history, last 90 and 30 days, this week since Monday): `agy-insights.js` runs `analyze()` and `findings()` once per period and `report.htmlViews()` renders them all, so switching in the browser needs no analysis code in the page and every number agrees with its period. The chart is inline SVG; its color (`--series`) is checked for contrast, chroma and lightness against both surfaces.
|
|
43
43
|
|
|
44
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
45
|
|
package/lib/i18n.js
CHANGED
|
@@ -142,12 +142,19 @@ Options:
|
|
|
142
142
|
filterLabel: 'Period',
|
|
143
143
|
rangeAll: 'All history',
|
|
144
144
|
rangeDays: (n) => `Last ${n} days`,
|
|
145
|
+
rangeWeek: 'This week',
|
|
145
146
|
chartTitleDay: 'Token usage per day',
|
|
146
147
|
chartTitleWeek: 'Token usage per week',
|
|
147
148
|
chartHint: 'Real tokens (input + output) of the model calls. Hover a column for details.',
|
|
148
149
|
chartTable: 'View as table',
|
|
149
|
-
|
|
150
|
-
|
|
150
|
+
chartModeLabel: 'Show',
|
|
151
|
+
chartModeTotal: 'Total',
|
|
152
|
+
chartModeCall: 'Per call',
|
|
153
|
+
chartHintCall: 'Mean tokens (input + output) per model call. Unlike the total, it doesn\'t depend on how much you used the agent: it drops when each call carries less context (shorter sessions, filtered command output), so it shows whether a change worked.',
|
|
154
|
+
perCallTip: (n) => `${n} tokens per call`,
|
|
155
|
+
noActivity: 'No activity',
|
|
156
|
+
chartCols: ['Day', 'Tokens', 'With cache', 'Calls', 'Per call'],
|
|
157
|
+
chartColsWeek: ['Week of', 'Tokens', 'With cache', 'Calls', 'Per call'],
|
|
151
158
|
chartCalls: (n) => plural(n, 'call', 'calls'),
|
|
152
159
|
weekOf: (d) => `week of ${d}`,
|
|
153
160
|
withCacheShort: 'with cache',
|
|
@@ -311,12 +318,19 @@ Opciones:
|
|
|
311
318
|
filterLabel: 'Periodo',
|
|
312
319
|
rangeAll: 'Todo el historial',
|
|
313
320
|
rangeDays: (n) => `Últimos ${n} días`,
|
|
321
|
+
rangeWeek: 'Esta semana',
|
|
314
322
|
chartTitleDay: 'Consumo de tokens por día',
|
|
315
323
|
chartTitleWeek: 'Consumo de tokens por semana',
|
|
316
324
|
chartHint: 'Tokens reales (entrada + salida) de las llamadas al modelo. Pasa el ratón por una columna para ver el detalle.',
|
|
317
325
|
chartTable: 'Ver como tabla',
|
|
318
|
-
|
|
319
|
-
|
|
326
|
+
chartModeLabel: 'Mostrar',
|
|
327
|
+
chartModeTotal: 'Total',
|
|
328
|
+
chartModeCall: 'Por llamada',
|
|
329
|
+
chartHintCall: 'Tokens medios (entrada + salida) por llamada al modelo. A diferencia del total, no depende de cuánto hayas usado el agente: baja cuando cada llamada lleva menos contexto (sesiones más cortas, salidas de comandos filtradas), así que muestra si un cambio ha funcionado.',
|
|
330
|
+
perCallTip: (n) => `${n} tokens por llamada`,
|
|
331
|
+
noActivity: 'Sin actividad',
|
|
332
|
+
chartCols: ['Día', 'Tokens', 'Con caché', 'Llamadas', 'Por llamada'],
|
|
333
|
+
chartColsWeek: ['Semana del', 'Tokens', 'Con caché', 'Llamadas', 'Por llamada'],
|
|
320
334
|
chartCalls: (n) => plural(n, 'llamada', 'llamadas'),
|
|
321
335
|
weekOf: (d) => `semana del ${d}`,
|
|
322
336
|
withCacheShort: 'con caché',
|
package/lib/report.js
CHANGED
|
@@ -115,12 +115,33 @@ function buckets(r) {
|
|
|
115
115
|
return { weekly, rows: [...byKey.values()] };
|
|
116
116
|
}
|
|
117
117
|
|
|
118
|
+
// Mean tokens per call: unlike the total, it doesn't follow how much the agent was used that day.
|
|
119
|
+
const perCall = (x) => (x.calls ? x.billed / x.calls : 0);
|
|
120
|
+
|
|
118
121
|
function chart(r) {
|
|
119
122
|
const b = buckets(r);
|
|
120
123
|
if (!b) return '';
|
|
124
|
+
const title = b.weekly ? t.report.chartTitleWeek : t.report.chartTitleDay;
|
|
125
|
+
const rows = b.rows.filter((x) => x.calls);
|
|
126
|
+
return `<section class="chart">
|
|
127
|
+
<h2>${title}</h2>
|
|
128
|
+
<div class="mode" role="group" aria-label="${esc(t.report.chartModeLabel)}">
|
|
129
|
+
<button type="button" data-mode="total" aria-pressed="true">${t.report.chartModeTotal}</button><button type="button" data-mode="call" aria-pressed="false">${t.report.chartModeCall}</button></div>
|
|
130
|
+
<p class="hint" data-mode="total">${t.report.chartHint}</p>
|
|
131
|
+
<p class="hint" data-mode="call" hidden>${t.report.chartHintCall}</p>
|
|
132
|
+
<div class="plot" data-mode="total">${plot(b, (x) => x.billed, (x) => `${fmt(x.billed)} tokens`, (x) => `${fmt(x.billed + x.cache)} ${t.report.withCacheShort}`, title)}</div>
|
|
133
|
+
<div class="plot" data-mode="call" hidden>${plot(b, perCall, (x) => t.report.perCallTip(fmt(perCall(x))), (x) => `${fmt(perCall(x) + x.cache / x.calls)} ${t.report.withCacheShort}`, `${title} · ${t.report.chartModeCall}`)}</div>
|
|
134
|
+
<details><summary>${t.report.chartTable}</summary><div class="scroll"><table><thead><tr>${(b.weekly ? t.report.chartColsWeek : t.report.chartCols).map((c, i) => `<th${i ? ' class="num"' : ''}>${c}</th>`).join('')}</tr></thead><tbody>
|
|
135
|
+
${rows.map((x) => `<tr><td>${esc(shortDate(x.date, true))}</td><td class="num">${fmt(x.billed)}</td><td class="num">${fmt(x.billed + x.cache)}</td><td class="num">${x.calls}</td><td class="num">${fmt(perCall(x))}</td></tr>`).join('')}
|
|
136
|
+
</tbody></table></div></details>
|
|
137
|
+
</section>`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// One column chart of `value` per bucket; `tip` and `cache` are the tooltip's headline and cache line.
|
|
141
|
+
function plot(b, value, tip, cache, label) {
|
|
121
142
|
const W = 900, H = 240, m = { l: 56, r: 8, t: 12, b: 28 };
|
|
122
143
|
const pw = W - m.l - m.r, ph = H - m.t - m.b;
|
|
123
|
-
const top = niceMax(Math.max(...b.rows.map(
|
|
144
|
+
const top = niceMax(Math.max(...b.rows.map(value)));
|
|
124
145
|
const band = pw / b.rows.length;
|
|
125
146
|
const bw = Math.max(1, Math.min(24, band - 2));
|
|
126
147
|
const y = (v) => m.t + ph - (v / top) * ph;
|
|
@@ -130,28 +151,20 @@ function chart(r) {
|
|
|
130
151
|
<text x="${m.l - 8}" y="${y(v) + 4}" text-anchor="end" class="axis">${esc(compact(v))}</text>`).join('');
|
|
131
152
|
// Rounded data end (4px), square at the baseline.
|
|
132
153
|
const bars = b.rows.map((x, i) => {
|
|
133
|
-
const h = (x
|
|
154
|
+
const h = (value(x) / top) * ph;
|
|
134
155
|
if (h <= 0) return '';
|
|
135
156
|
const x0 = m.l + i * band + (band - bw) / 2, y0 = m.t + ph - h, rr = Math.min(4, h, bw / 2);
|
|
136
157
|
return `<path class="col" d="M${x0},${m.t + ph}V${y0 + rr}Q${x0},${y0} ${x0 + rr},${y0}H${x0 + bw - rr}Q${x0 + bw},${y0} ${x0 + bw},${y0 + rr}V${m.t + ph}Z"/>`;
|
|
137
158
|
}).join('');
|
|
138
159
|
// Hit targets: the whole band, so a 2px column is as easy to hover as a 24px one.
|
|
139
160
|
const hits = b.rows.map((x, i) => `<rect class="hit" x="${m.l + i * band}" y="${m.t}" width="${band}" height="${ph}"
|
|
140
|
-
data-when="${esc(when(x))}" data-v="${esc(
|
|
161
|
+
data-when="${esc(when(x))}" data-v="${esc(x.calls ? tip(x) : t.report.noActivity)}" data-c="${esc(x.calls ? cache(x) : '')}" data-n="${esc(x.calls ? t.report.chartCalls(x.calls) : '')}"/>`).join('');
|
|
141
162
|
const every = Math.max(1, Math.ceil(b.rows.length / 6));
|
|
142
163
|
// A date every few columns, plus the last one when it isn't crowded by the previous label.
|
|
143
164
|
const last = b.rows.length - 1;
|
|
144
165
|
const ticks = b.rows.map((x, i) => (i % every === 0 || (i === last && last % every >= every / 2)) && `<text x="${i === 0 && band < 40 ? m.l : i === last && band < 40 ? W - m.r : m.l + i * band + band / 2}" y="${H - 8}" text-anchor="${i === 0 && band < 40 ? 'start' : i === last && band < 40 ? 'end' : 'middle'}" class="axis">${esc(shortDate(x.date))}</text>`).filter(Boolean).join('');
|
|
145
166
|
|
|
146
|
-
|
|
147
|
-
return `<section class="chart">
|
|
148
|
-
<h2>${b.weekly ? t.report.chartTitleWeek : t.report.chartTitleDay}</h2>
|
|
149
|
-
<p class="hint">${t.report.chartHint}</p>
|
|
150
|
-
<div class="plot"><svg viewBox="0 0 ${W} ${H}" role="img" aria-label="${esc(b.weekly ? t.report.chartTitleWeek : t.report.chartTitleDay)}">${grid}${bars}${ticks}${hits}</svg></div>
|
|
151
|
-
<details><summary>${t.report.chartTable}</summary><div class="scroll"><table><thead><tr>${(b.weekly ? t.report.chartColsWeek : t.report.chartCols).map((c, i) => `<th${i ? ' class="num"' : ''}>${c}</th>`).join('')}</tr></thead><tbody>
|
|
152
|
-
${rows.map((x) => `<tr><td>${esc(shortDate(x.date, true))}</td><td class="num">${fmt(x.billed)}</td><td class="num">${fmt(x.billed + x.cache)}</td><td class="num">${x.calls}</td></tr>`).join('')}
|
|
153
|
-
</tbody></table></div></details>
|
|
154
|
-
</section>`;
|
|
167
|
+
return `<svg viewBox="0 0 ${W} ${H}" role="img" aria-label="${esc(label)}">${grid}${bars}${ticks}${hits}</svg>`;
|
|
155
168
|
}
|
|
156
169
|
|
|
157
170
|
// --- Page ---
|
|
@@ -219,6 +232,12 @@ function htmlViews(views, { scope }) {
|
|
|
219
232
|
.filter button:hover { background: var(--accent-soft); }
|
|
220
233
|
.filter button[aria-pressed="true"] { background: var(--accent); border-color: var(--accent); color: var(--surface); font-weight: 600; }
|
|
221
234
|
.filter button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
235
|
+
.mode { display: inline-flex; margin: 0 0 0 12px; vertical-align: 3px; border: 1px solid var(--line); border-radius: 6px; overflow: hidden; }
|
|
236
|
+
.mode button { font: 13px system-ui, sans-serif; color: var(--fg); background: var(--surface); border: 0; padding: 3px 10px; cursor: pointer; }
|
|
237
|
+
.mode button + button { border-left: 1px solid var(--line); }
|
|
238
|
+
.mode button:hover { background: var(--accent-soft); }
|
|
239
|
+
.mode button[aria-pressed="true"] { background: var(--accent-soft); color: var(--accent); font-weight: 600; }
|
|
240
|
+
.mode button:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
|
222
241
|
.tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 12px; margin: 16px 0 20px; }
|
|
223
242
|
.tiles div { background: var(--surface); border: 1px solid var(--line); border-radius: 8px; padding: 12px 14px; }
|
|
224
243
|
.tiles b { display: block; font-size: 24px; font-variant-numeric: tabular-nums; } .tiles span { color: var(--muted); font-size: 14px; }
|
|
@@ -271,6 +290,13 @@ function htmlViews(views, { scope }) {
|
|
|
271
290
|
for (const s of document.querySelectorAll('.view')) s.hidden = s.dataset.view !== v.dataset.view;
|
|
272
291
|
return;
|
|
273
292
|
}
|
|
293
|
+
// Chart mode: the same in every period, so switching periods keeps it.
|
|
294
|
+
const m = e.target.closest('.mode button');
|
|
295
|
+
if (m) {
|
|
296
|
+
for (const b of document.querySelectorAll('.mode button')) b.setAttribute('aria-pressed', String(b.dataset.mode === m.dataset.mode));
|
|
297
|
+
for (const p of document.querySelectorAll('.chart [data-mode]:not(button)')) p.hidden = p.dataset.mode !== m.dataset.mode;
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
274
300
|
const b = e.target.closest('.copy'); if (!b) return;
|
|
275
301
|
const pre = b.parentElement.querySelector('pre');
|
|
276
302
|
const ok = () => { b.textContent = ${JSON.stringify(t.report.copied)}; setTimeout(() => (b.textContent = ${JSON.stringify(t.report.copy)}), 1500); };
|
|
@@ -285,9 +311,10 @@ function htmlViews(views, { scope }) {
|
|
|
285
311
|
const h = e.target.closest && e.target.closest('.hit');
|
|
286
312
|
if (!h) { tip.hidden = true; return; }
|
|
287
313
|
const [v, when, rest] = tip.children;
|
|
288
|
-
v.textContent = h.dataset.v
|
|
314
|
+
v.textContent = h.dataset.v;
|
|
289
315
|
when.textContent = h.dataset.when;
|
|
290
|
-
rest.textContent = h.dataset.n + ' · ' + h.dataset.c
|
|
316
|
+
rest.textContent = h.dataset.c ? h.dataset.n + ' · ' + h.dataset.c : h.dataset.n;
|
|
317
|
+
rest.hidden = !rest.textContent;
|
|
291
318
|
tip.hidden = false;
|
|
292
319
|
const x = Math.min(e.clientX + 14, innerWidth - tip.offsetWidth - 8);
|
|
293
320
|
tip.style.left = x + 'px';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agy-insights",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Measures Antigravity CLI token usage and shows which context is sent repeatedly or needlessly, in English or Spanish. Run: npx agy-insights@latest",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"keywords": [
|