changebook 0.4.3 → 0.4.4

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/dist/sync.js CHANGED
@@ -156,7 +156,10 @@ export function buildSection(rows, changes, alerts = [], projectName, pendingTas
156
156
  // · anunciar antes de atacar un encargo
157
157
  // · DECIR el riesgo al usuario (QA 2026-07-19: el guardian aviso, el
158
158
  // agente lo uso y siguio callado; desde fuera eso es no hacer nada)
159
- 'Memoria del proyecto en ChangeBook. Oriéntate con UNA llamada a `atlas_project_brief`. Antes de tocar un archivo: `atlas_file_context` con su ruta. Tras cada commit: `atlas_record_change` con el diff, `commit_hash`, `committed_at` y un `summary` tuyo (abarata el análisis; reenviar lo ya registrado es gratis).',
159
+ // · cargar las tools diferidas de UNA vez (benchmark 2026-07-20: 3 de 7
160
+ // llamadas del agente CON atlas eran ToolSearch cargando esquemas de
161
+ // uno en uno — cada una relee ~45k tokens de contexto)
162
+ 'Memoria del proyecto en ChangeBook. Oriéntate con UNA llamada a `atlas_project_brief`. Antes de tocar un archivo: `atlas_file_context` con su ruta. Tras cada commit: `atlas_record_change` con el diff, `commit_hash`, `committed_at` y un `summary` tuyo (abarata el análisis; reenviar lo ya registrado es gratis). Tools diferidas: una sola llamada a ToolSearch con todas, nunca de una en una.',
160
163
  '',
161
164
  ...(projectName
162
165
  ? [
package/dist/tools.js CHANGED
@@ -95,11 +95,37 @@ export function quotedInList(values) {
95
95
  .map((v) => `"${v.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`)
96
96
  .join(",");
97
97
  }
98
+ export const FILES_CAP = 8;
99
+ export function filesUnionByChange(rows, cap = FILES_CAP) {
100
+ const acc = new Map();
101
+ for (const r of rows) {
102
+ if (!Array.isArray(r.files))
103
+ continue;
104
+ let list = acc.get(r.changelog_id);
105
+ if (!list) {
106
+ list = [];
107
+ acc.set(r.changelog_id, list);
108
+ }
109
+ for (const f of r.files) {
110
+ if (typeof f === "string" && f.length > 0 && !list.includes(f)) {
111
+ list.push(f);
112
+ }
113
+ }
114
+ }
115
+ const out = new Map();
116
+ for (const [id, list] of acc) {
117
+ out.set(id, {
118
+ files: list.slice(0, cap),
119
+ more: Math.max(0, list.length - cap),
120
+ });
121
+ }
122
+ return out;
123
+ }
98
124
  // Consultation metering (never billing): each successful read leaves a row in
99
125
  // atlas_reads so the web can show "your agent consulted the atlas N times".
100
126
  // Best-effort and non-blocking — metering must never break or slow a read.
101
127
  // user_id is filled server-side (column default auth.uid()).
102
- function recordRead(db, tool, projectFilter, charsServed) {
128
+ function recordRead(db, tool, projectFilter, charsServed, latencyMs) {
103
129
  const projectId = /project_id=eq\.([0-9a-f-]+)/.exec(projectFilter)?.[1] ?? null;
104
130
  void db
105
131
  .insertRow("atlas_reads", {
@@ -107,6 +133,11 @@ function recordRead(db, tool, projectFilter, charsServed) {
107
133
  tool,
108
134
  source: "stdio",
109
135
  chars_served: charsServed,
136
+ // Latencia del handler (encargo 0dbbfbf4): nullable a propósito — es
137
+ // metering, jamás contrato, y un servidor viejo simplemente no la manda.
138
+ ...(latencyMs !== undefined
139
+ ? { latency_ms: Math.max(0, Math.min(Math.round(latencyMs), 600000)) }
140
+ : {}),
110
141
  })
111
142
  .catch(() => { });
112
143
  }
@@ -157,6 +188,76 @@ export async function derivaContraHead(dir, hash) {
157
188
  }
158
189
  // ── Registration ──────────────────────────────────────────────────────────────
159
190
  export function registerTools(server, db) {
191
+ // Espejo del hospedado (T3 del benchmark 2026-07-20): definiciones
192
+ // exportadas por archivo, sin grep. Usos y tests siguen fuera y se dice.
193
+ server.registerTool("atlas_symbol_lookup", {
194
+ title: "Where is this symbol defined?",
195
+ description: `Files where an exported symbol (function/class/const/interface/type/enum) is DEFINED, with its kind and the commit that last touched it. Mechanical index built from every ingested diff.
196
+
197
+ Definitions only — usages and tests are not indexed; grep for those. Exact match first, substring fallback.
198
+
199
+ Args:
200
+ - symbol (required): the identifier to look up.
201
+ - project (recommended): the repo you are working in (folder name or slug).
202
+
203
+ Returns (structured): { symbol, exact_match, matches: [{ symbol, file, kind, commit }] }`,
204
+ inputSchema: {
205
+ symbol: z
206
+ .string()
207
+ .min(2)
208
+ .max(120)
209
+ .describe("Identifier to look up (exported definition)"),
210
+ project: z
211
+ .string()
212
+ .min(1)
213
+ .max(120)
214
+ .describe("Project to scope to (repo folder name or slug)"),
215
+ },
216
+ annotations: {
217
+ readOnlyHint: true,
218
+ destructiveHint: false,
219
+ idempotentHint: true,
220
+ openWorldHint: true,
221
+ },
222
+ }, async ({ symbol, project }) => {
223
+ try {
224
+ const t0 = Date.now();
225
+ const pf = await db.projectFilterFor(project);
226
+ const base = `symbol_index?select=symbol,file,kind,commit_hash&order=symbol.asc&limit=20` +
227
+ pf;
228
+ let matches = await db.rest(`${base}&symbol=eq.${encodeURIComponent(symbol)}`);
229
+ let exact = true;
230
+ if (matches.length === 0) {
231
+ exact = false;
232
+ matches = await db.rest(`${base}&symbol=ilike.${ilikePattern(symbol)}`);
233
+ }
234
+ const lines = [`# Symbol lookup: ${symbol}`, ""];
235
+ if (matches.length === 0) {
236
+ lines.push("Not in the index. It may be unexported, renamed, or defined before the index existed — fall back to grep and SAY you did, instead of guessing.");
237
+ }
238
+ for (const m of matches) {
239
+ lines.push(`- ${m.symbol} (${m.kind}) — ${m.file}${m.commit_hash ? ` · commit ${m.commit_hash.slice(0, 7)}` : ""}`);
240
+ }
241
+ if (matches.length > 0) {
242
+ lines.push("", "Definitions only — usages and tests are not indexed; grep for those.");
243
+ }
244
+ const salida = toolResult(lines.join("\n"), {
245
+ symbol,
246
+ exact_match: exact,
247
+ matches: matches.map((m) => ({
248
+ symbol: m.symbol,
249
+ file: m.file,
250
+ kind: m.kind,
251
+ commit: m.commit_hash?.slice(0, 7) ?? null,
252
+ })),
253
+ });
254
+ recordRead(db, "atlas_symbol_lookup", pf, servedCharsOf(salida), Date.now() - t0);
255
+ return salida;
256
+ }
257
+ catch (error) {
258
+ return errorResult(error);
259
+ }
260
+ });
160
261
  server.registerTool("atlas_recent_changes", {
161
262
  title: "Recent ChangeBook changes",
162
263
  description: `List the most recent analyzed code changes from the ChangeBook changelog (newest first).
@@ -169,7 +270,7 @@ Args:
169
270
  - search (optional): case-insensitive text filter over the business and technical summaries.
170
271
  - project (recommended): the repo you are working in (folder name or slug). The atlas is per-project — always scope to your own project.
171
272
 
172
- Returns (structured): { count, offset, has_more, changes: [{ id, date, business_impact, diff_chars, modules: [{ module, risk }] }] }. Pass include_tech for summary_tech.
273
+ Returns (structured): { count, offset, has_more, changes: [{ id, date, commit, business_impact, diff_chars, files, files_more, modules: [{ module, risk }] }] }. files is a capped union (files_more = paths cut). Pass include_tech for summary_tech.
173
274
 
174
275
  Don't use for per-module deep dives — use atlas_module_detail for that.`,
175
276
  inputSchema: {
@@ -191,8 +292,9 @@ Don't use for per-module deep dives — use atlas_module_detail for that.`,
191
292
  },
192
293
  }, async ({ limit, offset, search, project, include_tech }) => {
193
294
  try {
295
+ const t0 = Date.now();
194
296
  const pf = await db.projectFilterFor(project);
195
- let query = `changelog?select=id,business_impact,summary_tech,created_at,diff_character_count` +
297
+ let query = `changelog?select=id,business_impact,summary_tech,created_at,diff_character_count,commit_hash` +
196
298
  `&order=created_at.desc&limit=${limit}&offset=${offset}` +
197
299
  pf;
198
300
  if (search) {
@@ -201,12 +303,14 @@ Don't use for per-module deep dives — use atlas_module_detail for that.`,
201
303
  }
202
304
  const rows = await db.rest(query);
203
305
  const modulesByChange = new Map();
306
+ let filesByChange = new Map();
204
307
  if (rows.length > 0) {
205
308
  const ids = rows.map((r) => r.id).join(",");
206
309
  const mods = await db.rest(
207
- // Only the module name and risk are used below; don't pull note/tech/
208
- // excerpt/files (up to ~1.5k each) for every row of every change.
209
- `change_module?select=changelog_id,module,risk&changelog_id=in.(${ids})`);
310
+ // note/tech/excerpt stay out (~1.5k each per row); `files` comes in
311
+ // deliberately (commit mirror, benchmark 2026-07-20) and is served
312
+ // as a capped union, never raw.
313
+ `change_module?select=changelog_id,module,risk,files&changelog_id=in.(${ids})`);
210
314
  for (const m of mods) {
211
315
  const list = modulesByChange.get(m.changelog_id);
212
316
  if (list)
@@ -214,6 +318,7 @@ Don't use for per-module deep dives — use atlas_module_detail for that.`,
214
318
  else
215
319
  modulesByChange.set(m.changelog_id, [m]);
216
320
  }
321
+ filesByChange = filesUnionByChange(mods);
217
322
  }
218
323
  const changes = rows.map((r) => ({
219
324
  id: r.id,
@@ -226,6 +331,9 @@ Don't use for per-module deep dives — use atlas_module_detail for that.`,
226
331
  // que casaron los resultados.
227
332
  ...(include_tech || search ? { summary_tech: r.summary_tech ?? null } : {}),
228
333
  diff_chars: r.diff_character_count ?? null,
334
+ commit: r.commit_hash?.slice(0, 7) ?? null,
335
+ files: filesByChange.get(r.id)?.files ?? [],
336
+ files_more: filesByChange.get(r.id)?.more ?? 0,
229
337
  modules: (modulesByChange.get(r.id) ?? []).map((m) => ({
230
338
  module: m.module,
231
339
  risk: m.risk,
@@ -242,11 +350,13 @@ Don't use for per-module deep dives — use atlas_module_detail for that.`,
242
350
  const mods = c.modules
243
351
  .map((m) => m.module + (m.risk ? ` [${m.risk}]` : ""))
244
352
  .join(", ");
245
- lines.push(`## ${c.date} — ${c.business_impact}`);
353
+ lines.push(`## ${c.date}${c.commit ? ` · ${c.commit}` : ""} — ${c.business_impact}`);
246
354
  if ((include_tech || search) && c.summary_tech)
247
355
  lines.push(`- Tech: ${c.summary_tech}`);
248
356
  if (mods)
249
357
  lines.push(`- Modules: ${mods}`);
358
+ if (c.files.length > 0)
359
+ lines.push(`- Files: ${c.files.join(", ")}${c.files_more > 0 ? ` (+${c.files_more} more)` : ""}`);
250
360
  lines.push("");
251
361
  }
252
362
  if (changes.length === 0) {
@@ -256,7 +366,7 @@ Don't use for per-module deep dives — use atlas_module_detail for that.`,
256
366
  }
257
367
  const changesText = lines.join("\n");
258
368
  const salida = toolResult(changesText, output);
259
- recordRead(db, "atlas_recent_changes", pf, servedCharsOf(salida));
369
+ recordRead(db, "atlas_recent_changes", pf, servedCharsOf(salida), Date.now() - t0);
260
370
  return salida;
261
371
  }
262
372
  catch (error) {
@@ -288,6 +398,7 @@ Returns (structured): { count, modules: [{ module, domain, risk, changes, last_c
288
398
  },
289
399
  }, async ({ domain, project }) => {
290
400
  try {
401
+ const t0 = Date.now();
291
402
  const pf = await db.projectFilterFor(project);
292
403
  let query =
293
404
  // The aggregation below uses only these columns; note/tech/excerpt
@@ -331,7 +442,7 @@ Returns (structured): { count, modules: [{ module, domain, risk, changes, last_c
331
442
  }
332
443
  const modulesText = lines.join("\n");
333
444
  const salida = toolResult(modulesText, output);
334
- recordRead(db, "atlas_modules", pf, servedCharsOf(salida));
445
+ recordRead(db, "atlas_modules", pf, servedCharsOf(salida), Date.now() - t0);
335
446
  return salida;
336
447
  }
337
448
  catch (error) {
@@ -372,6 +483,7 @@ Returns (structured): { module, count, changes: [{ date, commit, risk, note, tec
372
483
  },
373
484
  }, async ({ module, limit, include_excerpts, full, project }) => {
374
485
  try {
486
+ const t0 = Date.now();
375
487
  const pf = await db.projectFilterFor(project);
376
488
  const rows = await db.rest(`change_module?select=changelog_id,module,domain,category,risk,files,note,tech,excerpt,created_at` +
377
489
  `&module=eq.${encodeURIComponent(module)}&order=created_at.desc&limit=${limit}` +
@@ -462,7 +574,7 @@ Returns (structured): { module, count, changes: [{ date, commit, risk, note, tec
462
574
  lines.push(TEMPORAL_CONTRACT, "");
463
575
  const detailText = lines.join("\n");
464
576
  const salida = toolResult(detailText, output);
465
- recordRead(db, "atlas_module_detail", pf, servedCharsOf(salida));
577
+ recordRead(db, "atlas_module_detail", pf, servedCharsOf(salida), Date.now() - t0);
466
578
  return salida;
467
579
  }
468
580
  catch (error) {
@@ -471,7 +583,7 @@ Returns (structured): { module, count, changes: [{ date, commit, risk, note, tec
471
583
  });
472
584
  server.registerTool("atlas_file_context", {
473
585
  title: "Context of the files you are about to edit",
474
- description: `Everything the atlas knows about specific FILES: which module each belongs to, its risk, open regression alerts on those modules, and how often they changed recently.
586
+ description: `Everything the atlas knows about specific FILES: which module each belongs to, its risk, open regression alerts on those modules, how often they changed recently, and the CURRENT value of watched config constants (with the commit it comes from — no need to re-read the file for those).
475
587
 
476
588
  Call this BEFORE editing a file — one cheap call instead of re-reading the code and its git history.
477
589
 
@@ -479,7 +591,7 @@ Args:
479
591
  - files (required): 1-8 repo-relative paths.
480
592
  - project (recommended): the repo you are working in (folder name or slug). The atlas is per-project — always scope to your own project.
481
593
 
482
- Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_changed, last_note }], open_alerts }] }`,
594
+ Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_changed, last_note }], open_alerts, watched_values: [{ name, value, commit }] }] }`,
483
595
  inputSchema: {
484
596
  files: z.array(z.string().min(1).max(300)).min(1).max(8)
485
597
  .describe("Repo-relative paths you are about to edit"),
@@ -494,6 +606,7 @@ Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_
494
606
  },
495
607
  }, async ({ files, project }) => {
496
608
  try {
609
+ const t0 = Date.now();
497
610
  const pf = await db.projectFilterFor(project);
498
611
  const paths = [...new Set(files.map(normalizeRepoPath).filter(Boolean))];
499
612
  const perFile = await Promise.all(paths.map(async (file) => {
@@ -522,10 +635,24 @@ Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_
522
635
  const moduleNames = [
523
636
  ...new Set(perFile.flatMap((f) => f.modules.map((m) => m.module))),
524
637
  ];
525
- const alerts = moduleNames.length
526
- ? await db.rest(`regression_alerts?select=module,plain,evidence_symbol,evidence_expect&resolved_at=is.null&module=in.(${encodeURIComponent(quotedInList(moduleNames))})&order=created_at.desc&limit=10` +
638
+ const [alerts, watched] = await Promise.all([
639
+ moduleNames.length
640
+ ? db.rest(`regression_alerts?select=module,plain,evidence_symbol,evidence_expect&resolved_at=is.null&module=in.(${encodeURIComponent(quotedInList(moduleNames))})&order=created_at.desc&limit=10` +
641
+ pf)
642
+ : Promise.resolve([]),
643
+ // Constantes vigiladas (espejo del hospedado): el valor VIGENTE con
644
+ // su commit, extraído mecánicamente en cada ingesta — la cura del
645
+ // fallo de frescura del benchmark 2026-07-20. Best-effort: sin la
646
+ // tabla, el contexto sigue sirviéndose.
647
+ db
648
+ .rest(`watched_values?select=file,name,value,commit_hash&file=in.(${encodeURIComponent(quotedInList(paths))})&order=name.asc&limit=40` +
527
649
  pf)
528
- : [];
650
+ .catch(() => []),
651
+ ]);
652
+ const watchedByFile = new Map();
653
+ for (const w of watched) {
654
+ watchedByFile.set(w.file, [...(watchedByFile.get(w.file) ?? []), w]);
655
+ }
529
656
  // Refutación al servir (benchmark 2026-07-20): el mismo grep que el
530
657
  // guardián corre en el pre-commit, pero aquí, en la consulta que el
531
658
  // agente hace ANTES de editar — 4 de 7 alertas abiertas se caían con
@@ -576,13 +703,35 @@ Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_
576
703
  lines.push(` - ⚠ OPEN ALERT: ${plain}`);
577
704
  }
578
705
  }
706
+ for (const w of watchedByFile.get(f.file) ?? []) {
707
+ lines.push(`- Current value: ${w.name} = ${w.value}` +
708
+ (w.commit_hash
709
+ ? ` (as of commit ${w.commit_hash.slice(0, 7)})`
710
+ : ""));
711
+ }
579
712
  lines.push("");
580
713
  }
581
714
  if (!ancla)
582
715
  lines.push(TEMPORAL_CONTRACT, "");
583
716
  const contextText = lines.join("\n");
584
- const salida = toolResult(contextText, { files: perFile });
585
- recordRead(db, "atlas_file_context", pf, servedCharsOf(salida));
717
+ // open_alerts entra en el structured desde el 2026-07-20: la
718
+ // descripción lo prometía y solo viajaba en el texto (bug cazado por
719
+ // el verificador adversarial del benchmark).
720
+ const salida = toolResult(contextText, {
721
+ files: perFile.map((f) => ({
722
+ ...f,
723
+ open_alerts: f.modules.flatMap((m) => (alertsByModule.get(m.module) ?? []).map((plain) => ({
724
+ module: m.module,
725
+ plain,
726
+ }))),
727
+ watched_values: (watchedByFile.get(f.file) ?? []).map((w) => ({
728
+ name: w.name,
729
+ value: w.value,
730
+ commit: w.commit_hash?.slice(0, 7) ?? null,
731
+ })),
732
+ })),
733
+ });
734
+ recordRead(db, "atlas_file_context", pf, servedCharsOf(salida), Date.now() - t0);
586
735
  return salida;
587
736
  }
588
737
  catch (error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "changebook",
3
- "version": "0.4.3",
3
+ "version": "0.4.4",
4
4
  "mcpName": "io.github.raulbr90/changebook",
5
5
  "description": "ChangeBook for coding agents: MCP server (product memory for Claude Code/Codex) + CLI to sign in, analyze changes and sync the product map.",
6
6
  "type": "module",
package/server.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
3
3
  "name": "io.github.raulbr90/changebook",
4
4
  "description": "Query your product's living memory: module map + analyzed change history. Read-only MCP tools.",
5
- "version": "0.4.3",
5
+ "version": "0.4.4",
6
6
  "websiteUrl": "https://changebook.dev",
7
7
  "remotes": [
8
8
  {
@@ -15,7 +15,7 @@
15
15
  "registryType": "npm",
16
16
  "registryBaseUrl": "https://registry.npmjs.org",
17
17
  "identifier": "changebook",
18
- "version": "0.4.3",
18
+ "version": "0.4.4",
19
19
  "transport": {
20
20
  "type": "stdio"
21
21
  }