changebook 0.7.1 → 0.9.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,310 @@
1
+ /**
2
+ * `atlas_project_brief`: el brief de orientación, la herramienta que abre cada
3
+ * sesión.
4
+ *
5
+ * PORTADA DEL HOSPEDADO EL 09/08. Existía allí desde el 18/07 (`9a8387a`) y el
6
+ * bloque de CLAUDE.md que escribe este mismo paquete decía «orientarte →
7
+ * `atlas_project_brief`» desde entonces. Quien instalaba por npm leía la orden
8
+ * en cada sesión y no tenía con qué cumplirla.
9
+ *
10
+ * VIVE EN SU PROPIO FICHERO, y no por estética: `tools.ts` iba por 1.900 líneas
11
+ * y esto son otras 300. Es el mismo corte que el hospedado hizo el 03/08
12
+ * (AUD-C11). Los agregados puros que necesita salieron antes a `agregados.ts`,
13
+ * porque pedírselos a `tools.ts` —que importa este módulo para registrarlo—
14
+ * cerraría un ciclo.
15
+ *
16
+ * UNA LLAMADA, NO CUATRO. Cada llamada extra del agente relee ~45k tokens de
17
+ * contexto fijo (anatomía medida en el benchmark del 2026-07-20), mientras que
18
+ * los joins de aquí cuestan milisegundos. Por eso `intent` ENSANCHA esta misma
19
+ * llamada en vez de sugerir otra.
20
+ */
21
+ import { z } from "zod";
22
+ import { MODULE_GRAPH_WINDOW_ROWS, aggregateFileContext, briefModules, commitLabel, computeRecidivism, fileContainsFilter, filesUnionByChange, normalizeRepoPath, projectIdFromFilter, quotedInList, recordRead, } from "./agregados.js";
23
+ import { aliasesFor, canonicalizeModuleRows } from "./aliasDeModulo.js";
24
+ import { bloqueDeFriccion, consultaDeSucesos, friccionParaStructured, } from "./friccionDelBrief.js";
25
+ import { RO, day, errorResult, ilikePattern, servedCharsOf, toolResult, } from "./respuestas.js";
26
+ import { coChangePairs, summarizeHealth } from "./sync.js";
27
+ /**
28
+ * Ventana de filas de `change_module` sobre la que se calcula el mapa.
29
+ *
30
+ * ES UNA VENTANA, NO UN CENSO, y el brief lo dice con esas palabras. Medido el
31
+ * 2026-08-03: la ventana estaba llena al ras, así que el número de módulos BAJA
32
+ * según se trabaja. Llamarlo «total» hacía leer eso como módulos que
33
+ * desaparecen.
34
+ */
35
+ const MODULE_WINDOW = MODULE_GRAPH_WINDOW_ROWS;
36
+ const MAX_ALERTS = 10;
37
+ const MAX_TASKS = 10;
38
+ export function registrarProjectBrief(server, db) {
39
+ server.registerTool("atlas_project_brief", {
40
+ title: "Project brief (start here)",
41
+ description: "Call this FIRST, the moment a session opens, to get your bearings: it hands you the module map with risks, the open regression alerts, the project health (which safety controls the analysis has evidenced as passing or at-risk), the tasks the owner queued for you, and the latest analyzed changes — ONE call that replaces atlas_modules + atlas_pending_tasks + atlas_recent_changes. Optional intent widens the SAME call (never a second trip): intent=orient serves 10 recent changes with commit+files; intent=pre_edit with files=[paths] appends each file's modules, notes and current watched values; intent=feature with feature=\"text\" appends the changes matching that text. After reading it, tell the user in 1-3 lines what you found and what you plan to do, and WAIT for their OK before editing any code — announcing and then editing in the same turn leaves the owner nothing to decide. " +
42
+ "project: the repo you are working in (folder name or slug).",
43
+ inputSchema: {
44
+ project: z.string().min(1).max(120),
45
+ intent: z.enum(["orient", "pre_edit", "feature"]).optional(),
46
+ files: z.array(z.string().min(1).max(300)).min(1).max(8).optional(),
47
+ feature: z.string().min(2).max(120).optional(),
48
+ },
49
+ annotations: RO,
50
+ }, async ({ project, intent, files, feature }) => {
51
+ const t0 = Date.now();
52
+ try {
53
+ const pf = await db.projectFilterFor(project);
54
+ const projectId = projectIdFromFilter(pf);
55
+ // intent=orient sirve 10 cambios en vez de 5: la tarea de orientación
56
+ // del benchmark pide «los 10 commits más recientes», y con 5 el agente
57
+ // gastaba una segunda llamada para el resto.
58
+ const changesLimit = intent === "orient" ? 10 : 5;
59
+ // TODO EN PARALELO. Son consultas independientes y el arranque del
60
+ // agente es tiempo que el usuario ve. Las que pueden faltar por tabla
61
+ // ausente caen a vacío en vez de tumbar el brief entero.
62
+ const [modRowsCrudas, alertsCrudas, changes, healthRows, tasks, unanalyzed, { aliases }, filasDeFriccion,] = await Promise.all([
63
+ db.rest(`change_module?select=changelog_id,module,domain,risk,files,note,created_at` +
64
+ `&order=created_at.desc&limit=${MODULE_WINDOW}${pf}`),
65
+ db
66
+ .rest(`regression_alerts?select=id,module,plain,created_at,evidence_symbol,evidence_expect,evidence_scope,evidence_line,evidence_in_diff` +
67
+ `&resolved_at=is.null&order=created_at.desc&limit=${MAX_ALERTS}${pf}`)
68
+ .catch(() => []),
69
+ db.rest(`changelog?select=id,business_impact,summary_tech,created_at,diff_character_count,commit_hash,hash_aliases` +
70
+ `&order=created_at.desc&limit=${changesLimit}${pf}`),
71
+ db
72
+ .rest(`project_checks?select=check_id,status,evidence,updated_at&order=updated_at.desc&limit=8${pf}`)
73
+ .catch(() => []),
74
+ projectId
75
+ ? db
76
+ .callRpc("list_agent_tasks", {
77
+ p_project_id: projectId,
78
+ })
79
+ .then((rows) => rows.filter((r) => r.status === "pending").slice(0, MAX_TASKS))
80
+ .catch(() => [])
81
+ : Promise.resolve([]),
82
+ db
83
+ .rest(`unanalyzed_commits?select=commit_hash,committed_at,reason&order=committed_at.asc&limit=200${pf}`)
84
+ .catch(() => []),
85
+ aliasesFor(db, projectId),
86
+ // Dónde hay que rehacer el trabajo. Va en el MISMO viaje: son 8 filas
87
+ // en 30 días (medido), así que no añade latencia al arranque. Cae a
88
+ // vacío si la tabla no está, en vez de tumbar el brief.
89
+ db
90
+ .rest(consultaDeSucesos(pf, Date.now()))
91
+ .catch(() => []),
92
+ ]);
93
+ // SE CANONICALIZA AQUÍ Y UNA VEZ. `briefModules`, `coChangePairs` y
94
+ // `computeRecidivism` son puras sobre estas filas: resolviendo alias
95
+ // antes, las tres ven lo mismo. Resolver dentro de cada una es cómo la
96
+ // identidad del módulo acabó viviendo en tres sitios y divergiendo.
97
+ const modRows = canonicalizeModuleRows(modRowsCrudas, aliases);
98
+ const alerts = canonicalizeModuleRows(alertsCrudas, aliases);
99
+ const { modules, total: modulesTotal } = briefModules(modRows);
100
+ const parejas = coChangePairs(modRows);
101
+ const recidivism = computeRecidivism(alertsCrudas.map((a) => ({
102
+ module: a.module ?? null,
103
+ plain: a.plain ?? null,
104
+ resolution: a.resolution,
105
+ })));
106
+ const health = summarizeHealth(healthRows);
107
+ const filesByChange = filesUnionByChange(modRowsCrudas);
108
+ const lines = [`# ${project} — project brief`, ""];
109
+ // ARRIBA DEL TODO. Si el mapa está incompleto, eso condiciona todo lo
110
+ // que viene detrás y hay que saberlo ANTES de leerlo, no en una nota al
111
+ // pie.
112
+ if (unanalyzed.length > 0) {
113
+ const oldest = unanalyzed[0]?.committed_at;
114
+ // LA CAUSA CAMBIA LO QUE EL USUARIO TIENE QUE HACER, así que cambia
115
+ // lo que se le dice. Culparle de su cuota cuando el que falló fue
116
+ // ChangeBook sería mentirle, y encima le empujaría a pagar por algo
117
+ // que no arregla nada (incidente del 2026-07-19).
118
+ const provider = unanalyzed.filter((u) => u.reason === "provider_error").length;
119
+ const quota = unanalyzed.length - provider;
120
+ const causa = provider > 0 && quota > 0
121
+ ? `${quota} because the monthly analysis quota ran out, and ${provider} because ChangeBook could not reach the AI (a ChangeBook-side failure, not the user's fault)`
122
+ : provider > 0
123
+ ? `because ChangeBook could not reach the AI. This is a ChangeBook-side failure, NOT the user's quota and NOT their fault — they do not need to upgrade or pay anything`
124
+ : `because the monthly analysis quota ran out`;
125
+ const salida = provider > 0 && quota === 0
126
+ ? `They will be analyzable once the service recovers.`
127
+ : `Their atlas will keep drifting until the quota renews on the 1st or they upgrade.`;
128
+ lines.push(`> **This atlas is out of date.** ${unanalyzed.length} commit(s) ` +
129
+ `could not be analyzed${oldest ? ` (oldest: ${day(oldest)})` : ""} ` +
130
+ `${causa}. Their files and commit messages were recorded, but they ` +
131
+ `have no narrated summary and no regression check. Treat the module ` +
132
+ `map below as missing those changes, and TELL THE USER. ${salida}`, "");
133
+ }
134
+ lines.push(modulesTotal > modules.length
135
+ ? `## Modules (${modules.length} most recent of ${modulesTotal} seen in the last ${MODULE_WINDOW} changes — all of them in atlas_modules, same window)`
136
+ : `## Modules (${modulesTotal} in the last ${MODULE_WINDOW} changes)`);
137
+ for (const m of modules) {
138
+ // Reincidente: N problemas de regresión DISTINTOS a lo largo del
139
+ // tiempo. Es un hecho de los datos, no una corazonada.
140
+ const previas = recidivism.get(m.module);
141
+ lines.push(`- **${m.module}**${m.domain ? ` · ${m.domain}` : ""} — ${m.changes} change(s), last ${m.last_changed}` +
142
+ (m.risk ? `, risk: ${m.risk}` : "") +
143
+ (previas ? ` · ⚠ ${previas} prior regressions` : ""));
144
+ }
145
+ if (parejas.length > 0) {
146
+ lines.push("", "## Change together (touch one, check the other)");
147
+ for (const p of parejas) {
148
+ lines.push(`- **${p.a}** ↔ **${p.b}** — together in ${Math.round(p.rate * 100)}% of their changes`);
149
+ }
150
+ }
151
+ // Devuelve [] cuando no hay nada que decir, así que no deja cabecera
152
+ // huérfana. Va ANTES de los avisos abiertos, igual que en el hospedado.
153
+ lines.push(...bloqueDeFriccion(filasDeFriccion));
154
+ lines.push("", `## Open regression alerts (${alerts.length})`);
155
+ if (alerts.length === 0)
156
+ lines.push("None — no open alerts.");
157
+ for (const a of alerts) {
158
+ lines.push(`- ${(a.created_at ?? "").slice(0, 10)}${a.module ? ` · **${a.module}**` : ""} — ${a.plain ?? ""}`);
159
+ }
160
+ if (alerts.length > 0) {
161
+ lines.push("", "SAY THIS OUT LOUD to the user, in one or two lines, before you start " +
162
+ "working. They cannot see this brief. A warning you absorb silently and " +
163
+ "route around is indistinguishable, from where they sit, from a product " +
164
+ "that does nothing: they paid for this and never learn it fired. Name the " +
165
+ "module and what it puts at risk, in plain language, and only then get on " +
166
+ "with the task. Once you FIX or verify one, call atlas_resolve_alert({ " +
167
+ "project, module, verdict }) so it stops being re-raised and stops re-queuing " +
168
+ "the owner's task — an alert nobody closes is billed forever.");
169
+ }
170
+ lines.push("", `## Project health (${health.passed} passed, ${health.at_risk.length} at risk)`);
171
+ if (healthRows.length === 0) {
172
+ lines.push("Not yet evidenced — no analyzed change has touched a health control yet.");
173
+ }
174
+ else if (health.at_risk.length === 0) {
175
+ lines.push("All evidenced controls passing.");
176
+ }
177
+ else {
178
+ for (const h of health.at_risk) {
179
+ lines.push(`- ⚠ **${h.check}** at risk${h.since ? ` (since ${h.since})` : ""} — ${h.evidence}`);
180
+ }
181
+ lines.push("", "SAY THIS OUT LOUD to the user before you edit: a health control the " +
182
+ "analysis already caught as at-risk (an unauthorized endpoint, a " +
183
+ "hardcoded secret, a table-wide grant) is exactly what they cannot " +
184
+ "see. Name it in plain language, then get on with the task.");
185
+ }
186
+ lines.push("", `## Tasks the owner queued for you (${tasks.length})`);
187
+ if (tasks.length === 0)
188
+ lines.push("None pending.");
189
+ for (const t of tasks)
190
+ lines.push(`- [${t.id}] ${t.title}`);
191
+ if (tasks.length > 0) {
192
+ lines.push("", "Fetch a task's full body with atlas_pending_tasks({ task_id }). Tell the user which task you picked and your plan, and WAIT for their OK before touching code.");
193
+ }
194
+ lines.push("", `## Latest changes`);
195
+ for (const c of changes) {
196
+ // Con aliases post-squash: `abc1234 (=def5678)` — si el primero no
197
+ // existe en el main del lector, el segundo sí.
198
+ const commit = commitLabel(c.commit_hash, c.hash_aliases);
199
+ lines.push(`- ${day(c.created_at)}${commit ? ` · ${commit}` : ""} — ${c.business_impact ?? ""}`);
200
+ const served = filesByChange.get(c.id);
201
+ if (served && served.files.length > 0) {
202
+ lines.push(` files: ${served.files.join(", ")}${served.more > 0 ? ` (+${served.more} more)` : ""}`);
203
+ }
204
+ }
205
+ // ── intent=pre_edit: el contexto por fichero, en ESTA misma llamada ──
206
+ let preEdit = [];
207
+ if (intent === "pre_edit" && files && files.length > 0) {
208
+ const paths = [...new Set(files.map(normalizeRepoPath).filter(Boolean))];
209
+ const [perFileCrudo, watchedRows] = await Promise.all([
210
+ Promise.all(paths.map((p) => db
211
+ .rest(`change_module?select=changelog_id,module,risk,note,created_at&${fileContainsFilter(p)}&order=created_at.desc&limit=20${pf}`)
212
+ .then((rows) => ({ p, rows })))),
213
+ db
214
+ .rest(`watched_values?select=file,name,value,commit_hash&file=in.(${encodeURIComponent(quotedInList(paths))})&order=name.asc&limit=40${pf}`)
215
+ .catch(() => []),
216
+ ]);
217
+ // La agregación va DESPUÉS de canonicalizar: `aggregateFileContext`
218
+ // agrupa por slug, y canonicalizar después dejaría dos grupos para el
219
+ // mismo módulo.
220
+ preEdit = perFileCrudo.map(({ p, rows }) => {
221
+ const { file, modules: mods } = aggregateFileContext(p, canonicalizeModuleRows(rows, aliases));
222
+ return {
223
+ file,
224
+ modules: mods,
225
+ watched_values: watchedRows
226
+ .filter((w) => w.file === file)
227
+ .map((w) => ({
228
+ name: w.name,
229
+ value: w.value,
230
+ commit: w.commit_hash?.slice(0, 7) ?? null,
231
+ })),
232
+ };
233
+ });
234
+ lines.push("", `## Pre-edit context (${preEdit.length} file(s))`);
235
+ for (const f of preEdit) {
236
+ lines.push(`### ${f.file}`);
237
+ if (f.modules.length === 0) {
238
+ lines.push("No atlas history for this file yet (new or never analyzed).");
239
+ }
240
+ for (const m of f.modules) {
241
+ lines.push(`- Module **${m.module}** — ${m.changes} change(s), last ${m.last_changed}` +
242
+ (m.risk ? `, risk: ${m.risk}` : ""));
243
+ if (m.last_note) {
244
+ lines.push(` - Note from last analysis (${m.last_changed}): ${m.last_note}`);
245
+ }
246
+ }
247
+ for (const w of f.watched_values) {
248
+ lines.push(`- Current value: ${w.name} = ${w.value}` +
249
+ (w.commit ? ` (as of commit ${w.commit})` : ""));
250
+ }
251
+ }
252
+ }
253
+ else if (intent === "pre_edit") {
254
+ lines.push("", "intent=pre_edit needs files=[paths]; nothing appended.");
255
+ }
256
+ // ── intent=feature: los cambios que casan con un texto ───────────────
257
+ let featureMatches = [];
258
+ if (intent === "feature" && feature) {
259
+ const patron = ilikePattern(feature);
260
+ const rows = await db.rest(`changelog?select=id,business_impact,summary_tech,created_at,diff_character_count,commit_hash,hash_aliases` +
261
+ `&or=(business_impact.ilike.${patron},summary_tech.ilike.${patron})&order=created_at.desc&limit=5${pf}`);
262
+ featureMatches = rows.map((r) => ({
263
+ date: day(r.created_at),
264
+ commit: commitLabel(r.commit_hash, r.hash_aliases),
265
+ business_impact: r.business_impact ?? "",
266
+ }));
267
+ lines.push("", `## Changes matching "${feature}" (${rows.length})`);
268
+ if (rows.length === 0) {
269
+ lines.push("No analyzed change mentions it.");
270
+ }
271
+ for (const m of featureMatches) {
272
+ lines.push(`- ${m.date}${m.commit ? ` · ${m.commit}` : ""} — ${m.business_impact}`);
273
+ }
274
+ }
275
+ else if (intent === "feature") {
276
+ lines.push("", 'intent=feature needs feature="text"; nothing appended.');
277
+ }
278
+ const salida = toolResult(lines.join("\n"), {
279
+ project,
280
+ modules,
281
+ modules_total: modulesTotal,
282
+ couplings: parejas,
283
+ // Paridad con el texto, igual que en el hospedado: sin esto, lo único
284
+ // que dice dónde se rehace trabajo vive SÓLO dentro del markdown.
285
+ friction: friccionParaStructured(filasDeFriccion),
286
+ alerts: alerts.map((a) => ({
287
+ module: a.module,
288
+ plain: a.plain,
289
+ created_at: a.created_at ?? null,
290
+ })),
291
+ health,
292
+ tasks: tasks.map((t) => ({ id: t.id, title: t.title })),
293
+ changes: changes.map((c) => ({
294
+ date: day(c.created_at),
295
+ commit: commitLabel(c.commit_hash, c.hash_aliases),
296
+ business_impact: c.business_impact ?? "",
297
+ })),
298
+ unanalyzed: unanalyzed.length,
299
+ ...(preEdit.length > 0 ? { pre_edit: preEdit } : {}),
300
+ ...(featureMatches.length > 0 ? { feature_matches: featureMatches } : {}),
301
+ });
302
+ recordRead(db, "atlas_project_brief", pf, servedCharsOf(salida), Date.now() - t0);
303
+ return salida;
304
+ }
305
+ catch (error) {
306
+ return errorResult(error);
307
+ }
308
+ });
309
+ }
310
+ //# sourceMappingURL=toolProjectBrief.js.map
@@ -0,0 +1,128 @@
1
+ /**
2
+ * `atlas_usage`: coste, ahorro y valor de las consultas al atlas.
3
+ *
4
+ * PORTADA DEL HOSPEDADO EL 09/08. Nació allí de una pregunta de Raúl el
5
+ * 2026-07-18: «¿cuánto llevamos gastado / ahorrado?» era incontestable para
6
+ * cualquier agente, porque el dato solo vivía detrás de la sesión web. Las
7
+ * preguntas de dinero son preguntas de fundador; el atlas tiene que
8
+ * responderlas.
9
+ *
10
+ * NIVEL DE CUENTA, NO DE PROYECTO: la facturación es por cuenta, así que esta
11
+ * herramienta NO lleva `project`. Es la única del servidor que no lo lleva, y
12
+ * es a propósito.
13
+ */
14
+ import { z } from "zod";
15
+ import { recordRead } from "./agregados.js";
16
+ import { RO, errorResult, servedCharsOf, toolResult, } from "./respuestas.js";
17
+ import { fmtUsd, latencyByTool, summarizeUsage, } from "./usage.js";
18
+ /**
19
+ * Tope de filas por consulta de PostgREST. Se pagina hasta agotarlo y se AVISA
20
+ * si se alcanzó: un truncamiento leído como total convierte «has ahorrado $X»
21
+ * en una cifra que nadie puede auditar (AUD-C9). El techo de 20 páginas es un
22
+ * cortafuegos, no un limite de negocio — al alcanzarlo se marca truncado.
23
+ */
24
+ const PAGINA = 1000;
25
+ const MAX_PAGINAS = 20;
26
+ async function paginar(db, tabla, query, orden) {
27
+ const filas = [];
28
+ for (let i = 0; i < MAX_PAGINAS; i += 1) {
29
+ const page = await db.rest(`${tabla}?${query}&order=${orden}&limit=${PAGINA}&offset=${i * PAGINA}`);
30
+ filas.push(...page);
31
+ if (page.length < PAGINA)
32
+ return { filas, truncado: false };
33
+ }
34
+ return { filas, truncado: true };
35
+ }
36
+ export function registrarUsage(server, db) {
37
+ server.registerTool("atlas_usage", {
38
+ title: "Cost, savings and consultation value",
39
+ description: "Account-level usage summary: what the owner paid for analyses this period, what ChangeBook's optimizations avoided (compression, prompt cache, Batch API, model routing, dedup skips) and how much agents consulted the atlas (with a conservative exploration-avoided estimate). Use when the owner asks about spend, savings, cost or usage. Args: period 'month' (default) or 'all'.",
40
+ inputSchema: { period: z.enum(["month", "all"]).default("month") },
41
+ annotations: RO,
42
+ }, async ({ period }) => {
43
+ const t0 = Date.now();
44
+ try {
45
+ const now = new Date();
46
+ const since = period === "month"
47
+ ? new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)).toISOString()
48
+ : "1970-01-01T00:00:00Z";
49
+ const [ledgerP, skipsP, readsP, rolledUpP] = await Promise.all([
50
+ paginar(db, "usage_ledger", `select=model,action,input_tokens,output_tokens,cache_creation_input_tokens,cache_read_input_tokens,diff_chars,raw_diff_chars&created_at=gte.${since}`, "id.asc"),
51
+ paginar(db, "analysis_skips", `select=id&created_at=gte.${since}`, "id.asc").catch(() => ({ filas: [], truncado: false })),
52
+ paginar(db, "atlas_reads", `select=chars_served,source,tool,latency_ms&created_at=gte.${since}`, "id.asc").catch(() => ({ filas: [], truncado: false })),
53
+ // El resumen mensual de lo ya purgado. Sin esto, «all time» empezaría
54
+ // a significar «el último año» en cuanto la purga se llevara el primer
55
+ // mes, sin dejar de llamarse total (QA 2026-07-19). En period=month no
56
+ // aporta nada: el mes en curso nunca está purgado.
57
+ period === "all"
58
+ ? paginar(db, "atlas_reads_monthly", "select=reads,chars_served", "month.asc").catch(() => ({ filas: [], truncado: false }))
59
+ : Promise.resolve({ filas: [], truncado: false }),
60
+ ]);
61
+ const ledger = ledgerP.filas;
62
+ const reads = readsP.filas;
63
+ const rolledUp = rolledUpP.filas;
64
+ // SI CUALQUIERA SE TRUNCÓ, todo lo de abajo es un SUELO. Se dice en la
65
+ // salida en vez de servir la cifra a secas: quien lee «has ahorrado $X»
66
+ // tiene derecho a saber si eso es X o «al menos X».
67
+ const truncado = ledgerP.truncado ||
68
+ skipsP.truncado ||
69
+ readsP.truncado ||
70
+ rolledUpP.truncado;
71
+ // Los caracteres del guardián quedan FUERA de la estimación de
72
+ // exploración evitada: su contrafáctico es otro (evita un error, no una
73
+ // lectura). El resumen mensual ya viene guardado con esa exclusión.
74
+ const readsChars = reads
75
+ .filter((r) => r.source !== "guard")
76
+ .reduce((a, r) => a + (r.chars_served ?? 0), 0) +
77
+ rolledUp.reduce((a, r) => a + (r.chars_served ?? 0), 0);
78
+ const readsCount = reads.length + rolledUp.reduce((a, r) => a + (r.reads ?? 0), 0);
79
+ const u = summarizeUsage(ledger, skipsP.filas.length, readsCount, readsChars);
80
+ const naive = u.realCost + u.savedTotal;
81
+ const pct = naive > 0 ? Math.round((u.savedTotal / naive) * 100) : 0;
82
+ const lines = [
83
+ `# ChangeBook usage (${period === "month" ? "this month" : "all time"})`,
84
+ "",
85
+ ...(truncado
86
+ ? [
87
+ `> **These numbers are a FLOOR, not a total.** The usage tables for this period exceeded the read cap, so some rows were not counted. Every figure below is at least this much, possibly more.`,
88
+ "",
89
+ ]
90
+ : []),
91
+ `- Analyses paid: ${u.analyses} → ${fmtUsd(u.realCost)}`,
92
+ `- Avoided by optimizations: ${fmtUsd(u.savedTotal)} (${pct}% of the naive cost ${fmtUsd(naive)})`,
93
+ ` - Dedup skips (${u.skipsCount}): ${fmtUsd(u.saved.skips)}`,
94
+ ` - Batch API: ${fmtUsd(u.saved.batch)}`,
95
+ ` - Model routing: ${fmtUsd(u.saved.routing)}`,
96
+ ` - Prompt cache: ${fmtUsd(u.saved.cache)}`,
97
+ ` - Diff compression: ${fmtUsd(u.saved.compression)}`,
98
+ `- Agent consultations: ${u.reads.count} (≈${Math.round(u.reads.chars / 4)} tokens served; estimated exploration avoided ${fmtUsd(u.reads.explorationEstimate)} — conservative estimate, kept OUT of the hard savings above)`,
99
+ ];
100
+ const latency = latencyByTool(reads);
101
+ if (latency.length > 0) {
102
+ lines.push(`- Read latency by tool (server-side, ms):`);
103
+ for (const t of latency) {
104
+ lines.push(` - ${t.tool}: p50 ${t.p50_ms} · p95 ${t.p95_ms} (n=${t.n})`);
105
+ }
106
+ }
107
+ const salida = toolResult(lines.join("\n"), {
108
+ period,
109
+ // Que el consumidor programático también pueda distinguir un total de
110
+ // un suelo: si esto solo saliera en la prosa, cualquier panel que lea
111
+ // el JSON volvería a publicar la cifra truncada como exacta.
112
+ truncated: truncado,
113
+ analyses: u.analyses,
114
+ real_cost_usd: u.realCost,
115
+ saved_usd: u.saved,
116
+ saved_total_usd: u.savedTotal,
117
+ reads: u.reads,
118
+ latency_by_tool: latency,
119
+ });
120
+ recordRead(db, "atlas_usage", "", servedCharsOf(salida), Date.now() - t0);
121
+ return salida;
122
+ }
123
+ catch (error) {
124
+ return errorResult(error);
125
+ }
126
+ });
127
+ }
128
+ //# sourceMappingURL=toolUsage.js.map