changebook 0.3.2 → 0.4.1

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/tools.js CHANGED
@@ -55,6 +55,36 @@ function previewExcerpt(excerpt) {
55
55
  function day(iso) {
56
56
  return iso.slice(0, 10);
57
57
  }
58
+ // Pre-edit lookup helpers (mirror of the hosted scope.ts — the npm package
59
+ // must stay self-contained, so these three stay tiny and duplicated).
60
+ // Exported so test/mcpParity.test.ts can pin them equal to the hosted copies:
61
+ // drift would break atlas_file_context on stdio silently (audit M7).
62
+ export function normalizeRepoPath(path) {
63
+ return path.trim().replace(/^\.\//, "").replace(/^\/+/, "");
64
+ }
65
+ export function fileContainsFilter(path) {
66
+ return `files=cs.${encodeURIComponent(JSON.stringify([path]))}`;
67
+ }
68
+ export function quotedInList(values) {
69
+ return values
70
+ .map((v) => `"${v.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`)
71
+ .join(",");
72
+ }
73
+ // Consultation metering (never billing): each successful read leaves a row in
74
+ // atlas_reads so the web can show "your agent consulted the atlas N times".
75
+ // Best-effort and non-blocking — metering must never break or slow a read.
76
+ // user_id is filled server-side (column default auth.uid()).
77
+ function recordRead(db, tool, projectFilter, charsServed) {
78
+ const projectId = /project_id=eq\.([0-9a-f-]+)/.exec(projectFilter)?.[1] ?? null;
79
+ void db
80
+ .insertRow("atlas_reads", {
81
+ project_id: projectId,
82
+ tool,
83
+ source: "stdio",
84
+ chars_served: charsServed,
85
+ })
86
+ .catch(() => { });
87
+ }
58
88
  /** PostgREST `or=(...ilike...)` needs the pattern URL-encoded once. */
59
89
  function ilikePattern(search) {
60
90
  return encodeURIComponent(`*${search.replace(/[%*,()]/g, " ").trim()}*`);
@@ -71,6 +101,7 @@ Args:
71
101
  - limit (1-50, default 10): entries to return.
72
102
  - offset (default 0): pagination offset.
73
103
  - search (optional): case-insensitive text filter over the business and technical summaries.
104
+ - project (recommended): the repo you are working in (folder name or slug). The atlas is per-project — always scope to your own project.
74
105
 
75
106
  Returns (structured): { count, offset, has_more, changes: [{ id, date, business_impact, summary_tech, diff_chars, modules: [{ module, risk }] }] }
76
107
 
@@ -82,6 +113,8 @@ Don't use for per-module deep dives — use atlas_module_detail for that.`,
82
113
  .describe("Pagination offset"),
83
114
  search: z.string().min(2).max(120).optional()
84
115
  .describe("Case-insensitive filter over summaries"),
116
+ project: z.string().min(1).max(120).optional()
117
+ .describe("Project to scope to (repo folder name or slug)"),
85
118
  },
86
119
  annotations: {
87
120
  readOnlyHint: true,
@@ -89,11 +122,12 @@ Don't use for per-module deep dives — use atlas_module_detail for that.`,
89
122
  idempotentHint: true,
90
123
  openWorldHint: true,
91
124
  },
92
- }, async ({ limit, offset, search }) => {
125
+ }, async ({ limit, offset, search, project }) => {
93
126
  try {
127
+ const pf = await db.projectFilterFor(project);
94
128
  let query = `changelog?select=id,business_impact,summary_tech,created_at,diff_character_count` +
95
129
  `&order=created_at.desc&limit=${limit}&offset=${offset}` +
96
- (await db.projectFilter());
130
+ pf;
97
131
  if (search) {
98
132
  const p = ilikePattern(search);
99
133
  query += `&or=(business_impact.ilike.${p},summary_tech.ilike.${p})`;
@@ -148,7 +182,9 @@ Don't use for per-module deep dives — use atlas_module_detail for that.`,
148
182
  ? `No changes match "${search}". Try a broader search or omit it.`
149
183
  : "No analyzed changes yet. Analyze a diff from the ChangeBook extension first.");
150
184
  }
151
- return toolResult(lines.join("\n"), output);
185
+ const changesText = lines.join("\n");
186
+ recordRead(db, "atlas_recent_changes", pf, changesText.length);
187
+ return toolResult(changesText, output);
152
188
  }
153
189
  catch (error) {
154
190
  return errorResult(error);
@@ -162,11 +198,14 @@ For each module: domain, category, latest risk level, number of analyzed changes
162
198
 
163
199
  Args:
164
200
  - domain (optional): filter by domain (e.g. "billing").
201
+ - project (recommended): the repo you are working in (folder name or slug). The atlas is per-project — always scope to your own project.
165
202
 
166
203
  Returns (structured): { count, modules: [{ module, domain, category, risk, changes, last_changed, files }] }`,
167
204
  inputSchema: {
168
205
  domain: z.string().min(1).max(80).optional()
169
206
  .describe("Only modules in this domain"),
207
+ project: z.string().min(1).max(120).optional()
208
+ .describe("Project to scope to (repo folder name or slug)"),
170
209
  },
171
210
  annotations: {
172
211
  readOnlyHint: true,
@@ -174,14 +213,15 @@ Returns (structured): { count, modules: [{ module, domain, category, risk, chang
174
213
  idempotentHint: true,
175
214
  openWorldHint: true,
176
215
  },
177
- }, async ({ domain }) => {
216
+ }, async ({ domain, project }) => {
178
217
  try {
218
+ const pf = await db.projectFilterFor(project);
179
219
  let query =
180
220
  // The aggregation below uses only these columns; note/tech/excerpt
181
221
  // (up to ~1.5k each × 1000 rows) would move 1-2 MB per call for nothing.
182
222
  `change_module?select=module,domain,category,risk,files,created_at` +
183
223
  `&order=created_at.desc&limit=1000` +
184
- (await db.projectFilter());
224
+ pf;
185
225
  if (domain)
186
226
  query += `&domain=eq.${encodeURIComponent(domain)}`;
187
227
  const rows = await db.rest(query);
@@ -219,7 +259,9 @@ Returns (structured): { count, modules: [{ module, domain, category, risk, chang
219
259
  ? `No modules in domain "${domain}". Call atlas_modules without a domain to see all.`
220
260
  : "No modules yet. Analyze a diff from the ChangeBook extension first.");
221
261
  }
222
- return toolResult(lines.join("\n"), output);
262
+ const modulesText = lines.join("\n");
263
+ recordRead(db, "atlas_modules", pf, modulesText.length);
264
+ return toolResult(modulesText, output);
223
265
  }
224
266
  catch (error) {
225
267
  return errorResult(error);
@@ -236,6 +278,7 @@ Args:
236
278
  - limit (1-20, default 5): number of recent changes to include.
237
279
  - include_excerpts (default true): include diff excerpts (short previews unless full is set).
238
280
  - full (default false): return the diff excerpts verbatim instead of the token-saving previews. Only pass it when you actually need the code lines.
281
+ - project (recommended): the repo you are working in (folder name or slug). The atlas is per-project — always scope to your own project.
239
282
 
240
283
  Returns (structured): { module, count, changes: [{ date, risk, note, tech, files, business_impact, excerpt, excerpt_truncated }] }`,
241
284
  inputSchema: {
@@ -247,6 +290,8 @@ Returns (structured): { module, count, changes: [{ date, risk, note, tech, files
247
290
  .describe("Include diff excerpts (previews unless full)"),
248
291
  full: z.boolean().default(false)
249
292
  .describe("Verbatim excerpts instead of short previews"),
293
+ project: z.string().min(1).max(120).optional()
294
+ .describe("Project to scope to (repo folder name or slug)"),
250
295
  },
251
296
  annotations: {
252
297
  readOnlyHint: true,
@@ -254,11 +299,12 @@ Returns (structured): { module, count, changes: [{ date, risk, note, tech, files
254
299
  idempotentHint: true,
255
300
  openWorldHint: true,
256
301
  },
257
- }, async ({ module, limit, include_excerpts, full }) => {
302
+ }, async ({ module, limit, include_excerpts, full, project }) => {
258
303
  try {
304
+ const pf = await db.projectFilterFor(project);
259
305
  const rows = await db.rest(`change_module?select=changelog_id,module,domain,category,risk,files,note,tech,excerpt,created_at` +
260
306
  `&module=eq.${encodeURIComponent(module)}&order=created_at.desc&limit=${limit}` +
261
- (await db.projectFilter()));
307
+ pf);
262
308
  if (rows.length === 0) {
263
309
  return {
264
310
  content: [
@@ -328,7 +374,98 @@ Returns (structured): { module, count, changes: [{ date, risk, note, tech, files
328
374
  }
329
375
  lines.push("");
330
376
  }
331
- return toolResult(lines.join("\n"), output);
377
+ const detailText = lines.join("\n");
378
+ recordRead(db, "atlas_module_detail", pf, detailText.length);
379
+ return toolResult(detailText, output);
380
+ }
381
+ catch (error) {
382
+ return errorResult(error);
383
+ }
384
+ });
385
+ server.registerTool("atlas_file_context", {
386
+ title: "Context of the files you are about to edit",
387
+ 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.
388
+
389
+ Call this BEFORE editing a file — one cheap call instead of re-reading the code and its git history.
390
+
391
+ Args:
392
+ - files (required): 1-8 repo-relative paths.
393
+ - project (recommended): the repo you are working in (folder name or slug). The atlas is per-project — always scope to your own project.
394
+
395
+ Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_changed, last_note }], open_alerts }] }`,
396
+ inputSchema: {
397
+ files: z.array(z.string().min(1).max(300)).min(1).max(8)
398
+ .describe("Repo-relative paths you are about to edit"),
399
+ project: z.string().min(1).max(120).optional()
400
+ .describe("Project to scope to (repo folder name or slug)"),
401
+ },
402
+ annotations: {
403
+ readOnlyHint: true,
404
+ destructiveHint: false,
405
+ idempotentHint: true,
406
+ openWorldHint: true,
407
+ },
408
+ }, async ({ files, project }) => {
409
+ try {
410
+ const pf = await db.projectFilterFor(project);
411
+ const paths = [...new Set(files.map(normalizeRepoPath).filter(Boolean))];
412
+ const perFile = await Promise.all(paths.map(async (file) => {
413
+ const rows = await db.rest(`change_module?select=module,risk,note,created_at&${fileContainsFilter(file)}&order=created_at.desc&limit=20` +
414
+ pf);
415
+ const byModule = new Map();
416
+ for (const row of rows) {
417
+ const name = (row.module ?? "").trim();
418
+ if (!name)
419
+ continue;
420
+ const existing = byModule.get(name);
421
+ if (existing)
422
+ existing.changes += 1;
423
+ else {
424
+ byModule.set(name, {
425
+ module: name,
426
+ risk: row.risk,
427
+ changes: 1,
428
+ last_changed: day(row.created_at),
429
+ last_note: row.note,
430
+ });
431
+ }
432
+ }
433
+ return { file, modules: [...byModule.values()] };
434
+ }));
435
+ const moduleNames = [
436
+ ...new Set(perFile.flatMap((f) => f.modules.map((m) => m.module))),
437
+ ];
438
+ const alerts = moduleNames.length
439
+ ? await db.rest(`regression_alerts?select=module,plain&resolved_at=is.null&module=in.(${encodeURIComponent(quotedInList(moduleNames))})&order=created_at.desc&limit=10` +
440
+ pf)
441
+ : [];
442
+ const alertsByModule = new Map();
443
+ for (const a of alerts) {
444
+ const m = (a.module ?? "").trim();
445
+ if (!m || !a.plain)
446
+ continue;
447
+ alertsByModule.set(m, [...(alertsByModule.get(m) ?? []), a.plain]);
448
+ }
449
+ const lines = [`# File context (${paths.length} file(s))`, ""];
450
+ for (const f of perFile) {
451
+ lines.push(`## ${f.file}`);
452
+ if (f.modules.length === 0) {
453
+ lines.push("No atlas history for this file yet (new or never analyzed).");
454
+ }
455
+ for (const m of f.modules) {
456
+ lines.push(`- Module **${m.module}** — ${m.changes} change(s), last ${m.last_changed}` +
457
+ (m.risk ? `, risk: ${m.risk}` : ""));
458
+ if (m.last_note)
459
+ lines.push(` - Latest note: ${m.last_note}`);
460
+ for (const plain of alertsByModule.get(m.module) ?? []) {
461
+ lines.push(` - ⚠ OPEN ALERT: ${plain}`);
462
+ }
463
+ }
464
+ lines.push("");
465
+ }
466
+ const contextText = lines.join("\n");
467
+ recordRead(db, "atlas_file_context", pf, contextText.length);
468
+ return toolResult(contextText, { files: perFile });
332
469
  }
333
470
  catch (error) {
334
471
  return errorResult(error);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "changebook",
3
- "version": "0.3.2",
3
+ "version": "0.4.1",
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.3.3",
5
+ "version": "0.4.1",
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.3.2",
18
+ "version": "0.4.1",
19
19
  "transport": {
20
20
  "type": "stdio"
21
21
  }