@jussmor/commit-memory-mcp 0.3.5 → 0.3.7

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.
@@ -6,14 +6,59 @@ import { execFileSync } from "node:child_process";
6
6
  import fs from "node:fs";
7
7
  import path from "node:path";
8
8
  import { pathToFileURL } from "node:url";
9
- import { openDatabase } from "../db/client.js";
10
- import { indexRepository } from "../indexer.js";
11
- import { explainCommitMatch, searchRelatedCommits } from "../search/query.js";
12
- function runGitDiff(repoPath, sha) {
13
- return execFileSync("git", ["-C", repoPath, "show", "--no-color", "--stat", "--patch", sha], {
14
- encoding: "utf8",
9
+ import { archiveFeatureContext, buildContextPack, openDatabase, promoteContextFacts, upsertWorktreeSession, } from "../db/client.js";
10
+ import { commitDetails, latestCommitForFile, mainBranchOvernightBrief, resumeFeatureSessionBrief, whoChangedFile, } from "../git/insights.js";
11
+ import { listActiveWorktrees } from "../git/worktree.js";
12
+ import { syncPullRequestContext } from "../pr/sync.js";
13
+ function fetchRemote(repoPath) {
14
+ execFileSync("git", ["-C", repoPath, "fetch", "--all", "--prune"], {
15
+ stdio: ["ignore", "pipe", "pipe"],
15
16
  });
16
17
  }
18
+ function detectReferencedPrNumber(text) {
19
+ const match = text.match(/#(\d{1,8})\b/);
20
+ if (!match) {
21
+ return null;
22
+ }
23
+ const value = Number.parseInt(match[1] ?? "", 10);
24
+ if (!Number.isFinite(value) || value <= 0) {
25
+ return null;
26
+ }
27
+ return value;
28
+ }
29
+ function loadPullRequestContext(db, prNumber, repoOwner, repoName) {
30
+ const pr = repoOwner && repoName
31
+ ? (db
32
+ .prepare(`
33
+ SELECT repo_owner, repo_name, pr_number, title, body, author, state, created_at, updated_at, merged_at, url
34
+ FROM prs
35
+ WHERE repo_owner = ? AND repo_name = ? AND pr_number = ?
36
+ LIMIT 1
37
+ `)
38
+ .get(repoOwner, repoName, prNumber) ?? null)
39
+ : (db
40
+ .prepare(`
41
+ SELECT repo_owner, repo_name, pr_number, title, body, author, state, created_at, updated_at, merged_at, url
42
+ FROM prs
43
+ WHERE pr_number = ?
44
+ ORDER BY updated_at DESC
45
+ LIMIT 1
46
+ `)
47
+ .get(prNumber) ?? null);
48
+ if (!pr) {
49
+ return { pr: null, decisions: [] };
50
+ }
51
+ const decisions = db
52
+ .prepare(`
53
+ SELECT id, source, author, summary, severity, created_at
54
+ FROM pr_decisions
55
+ WHERE repo_owner = ? AND repo_name = ? AND pr_number = ?
56
+ ORDER BY created_at DESC
57
+ LIMIT 50
58
+ `)
59
+ .all(pr.repo_owner, pr.repo_name, pr.pr_number);
60
+ return { pr, decisions };
61
+ }
17
62
  function getConfig() {
18
63
  const repoPath = path.resolve(process.env.COMMIT_RAG_REPO ?? process.cwd());
19
64
  const dbPath = path.resolve(process.env.COMMIT_RAG_DB ?? path.join(repoPath, ".commit-rag.db"));
@@ -23,7 +68,7 @@ function getConfig() {
23
68
  export async function startMcpServer() {
24
69
  const server = new Server({
25
70
  name: "commit-memory-mcp",
26
- version: "0.3.1",
71
+ version: "0.4.0",
27
72
  }, {
28
73
  capabilities: {
29
74
  tools: {},
@@ -32,117 +77,442 @@ export async function startMcpServer() {
32
77
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
33
78
  tools: [
34
79
  {
35
- name: "search_related_commits",
36
- description: "Find commit chunks semantically related to current work context.",
80
+ name: "sync_pr_context",
81
+ description: "Sync pull request description/comments/reviews from GitHub CLI into local context DB.",
37
82
  inputSchema: {
38
83
  type: "object",
39
84
  properties: {
40
- query: { type: "string" },
41
- activeFile: { type: "string" },
85
+ owner: { type: "string" },
86
+ repo: { type: "string" },
87
+ prNumbers: {
88
+ type: "array",
89
+ items: { type: "number" },
90
+ },
91
+ domain: { type: "string" },
92
+ feature: { type: "string" },
93
+ branch: { type: "string" },
94
+ taskType: { type: "string" },
42
95
  limit: { type: "number" },
43
96
  },
44
- required: ["query"],
97
+ required: ["owner", "repo"],
45
98
  },
46
99
  },
47
100
  {
48
- name: "explain_commit_match",
49
- description: "Return contextual details for a chunk match.",
101
+ name: "build_context_pack",
102
+ description: "Build a scoped context pack for a task/domain/feature/branch to keep agent prompts small.",
50
103
  inputSchema: {
51
104
  type: "object",
52
105
  properties: {
53
- chunkId: { type: "string" },
106
+ domain: { type: "string" },
107
+ feature: { type: "string" },
108
+ branch: { type: "string" },
109
+ taskType: { type: "string" },
110
+ includeDraft: { type: "boolean" },
111
+ limit: { type: "number" },
54
112
  },
55
- required: ["chunkId"],
113
+ required: [],
56
114
  },
57
115
  },
58
116
  {
59
- name: "get_commit_diff",
60
- description: "Get full git show output for a commit SHA.",
117
+ name: "promote_context_facts",
118
+ description: "Promote scoped draft facts into durable promoted context.",
119
+ inputSchema: {
120
+ type: "object",
121
+ properties: {
122
+ domain: { type: "string" },
123
+ feature: { type: "string" },
124
+ branch: { type: "string" },
125
+ sourceType: { type: "string" },
126
+ },
127
+ required: [],
128
+ },
129
+ },
130
+ {
131
+ name: "archive_feature_context",
132
+ description: "Archive all active facts for a domain/feature once work is complete.",
133
+ inputSchema: {
134
+ type: "object",
135
+ properties: {
136
+ domain: { type: "string" },
137
+ feature: { type: "string" },
138
+ },
139
+ required: ["domain", "feature"],
140
+ },
141
+ },
142
+ {
143
+ name: "list_active_worktrees",
144
+ description: "List active git worktrees for multi-session feature work.",
145
+ inputSchema: {
146
+ type: "object",
147
+ properties: {
148
+ baseBranch: { type: "string" },
149
+ },
150
+ required: [],
151
+ },
152
+ },
153
+ {
154
+ name: "who_changed_this",
155
+ description: "Show who changed a file recently and summarize top authors.",
156
+ inputSchema: {
157
+ type: "object",
158
+ properties: {
159
+ filePath: { type: "string" },
160
+ limit: { type: "number" },
161
+ },
162
+ required: ["filePath"],
163
+ },
164
+ },
165
+ {
166
+ name: "why_was_this_changed",
167
+ description: "Explain intent for a commit or file using git history and synced PR decisions.",
61
168
  inputSchema: {
62
169
  type: "object",
63
170
  properties: {
64
171
  sha: { type: "string" },
172
+ filePath: { type: "string" },
173
+ owner: { type: "string" },
174
+ repo: { type: "string" },
65
175
  },
66
- required: ["sha"],
176
+ required: [],
67
177
  },
68
178
  },
69
179
  {
70
- name: "reindex_commits",
71
- description: "Refresh commit index from git history.",
180
+ name: "get_main_branch_overnight_brief",
181
+ description: "Summarize what changed recently on main branch while you were offline.",
72
182
  inputSchema: {
73
183
  type: "object",
74
184
  properties: {
185
+ baseBranch: { type: "string" },
186
+ sinceHours: { type: "number" },
75
187
  limit: { type: "number" },
76
188
  },
77
189
  required: [],
78
190
  },
79
191
  },
192
+ {
193
+ name: "resume_feature_session_brief",
194
+ description: "Brief branch divergence and overlap risk for a feature worktree.",
195
+ inputSchema: {
196
+ type: "object",
197
+ properties: {
198
+ worktreePath: { type: "string" },
199
+ baseBranch: { type: "string" },
200
+ },
201
+ required: [],
202
+ },
203
+ },
204
+ {
205
+ name: "pre_plan_sync_brief",
206
+ description: "Run sync + overnight + feature resume analysis before planning work.",
207
+ inputSchema: {
208
+ type: "object",
209
+ properties: {
210
+ owner: { type: "string" },
211
+ repo: { type: "string" },
212
+ baseBranch: { type: "string" },
213
+ worktreePath: { type: "string" },
214
+ filePath: { type: "string" },
215
+ sinceHours: { type: "number" },
216
+ limit: { type: "number" },
217
+ },
218
+ required: ["owner", "repo"],
219
+ },
220
+ },
80
221
  ],
81
222
  }));
82
223
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
83
224
  const { repoPath, dbPath, limit: defaultLimit } = getConfig();
84
- if (request.params.name === "reindex_commits") {
85
- const limit = Number(request.params.arguments?.limit ?? defaultLimit);
86
- const summary = await indexRepository({ repoPath, dbPath, limit });
225
+ if (request.params.name === "sync_pr_context") {
226
+ const owner = String(request.params.arguments?.owner ?? "").trim();
227
+ const repo = String(request.params.arguments?.repo ?? "").trim();
228
+ if (!owner || !repo) {
229
+ return {
230
+ content: [{ type: "text", text: "owner and repo are required" }],
231
+ isError: true,
232
+ };
233
+ }
234
+ const numbersRaw = request.params.arguments?.prNumbers;
235
+ const prNumbers = Array.isArray(numbersRaw)
236
+ ? numbersRaw
237
+ .map((value) => Number(value))
238
+ .filter((value) => Number.isFinite(value) && value > 0)
239
+ : undefined;
240
+ const limit = Number(request.params.arguments?.limit ?? 25);
241
+ const domain = String(request.params.arguments?.domain ?? "").trim();
242
+ const feature = String(request.params.arguments?.feature ?? "").trim();
243
+ const branch = String(request.params.arguments?.branch ?? "").trim();
244
+ const taskType = String(request.params.arguments?.taskType ?? "").trim();
245
+ const summary = await syncPullRequestContext({
246
+ repoPath,
247
+ dbPath,
248
+ repoOwner: owner,
249
+ repoName: repo,
250
+ prNumbers,
251
+ limit,
252
+ domain: domain || undefined,
253
+ feature: feature || undefined,
254
+ branch: branch || undefined,
255
+ taskType: taskType || undefined,
256
+ });
87
257
  return {
88
258
  content: [{ type: "text", text: JSON.stringify(summary, null, 2) }],
89
259
  };
90
260
  }
91
- const db = openDatabase(dbPath);
92
- try {
93
- if (request.params.name === "search_related_commits") {
94
- const query = String(request.params.arguments?.query ?? "").trim();
95
- const activeFile = request.params.arguments?.activeFile
96
- ? String(request.params.arguments.activeFile)
97
- : undefined;
98
- const limit = Number(request.params.arguments?.limit ?? 8);
99
- if (!query) {
100
- return {
101
- content: [{ type: "text", text: "query is required" }],
102
- isError: true,
103
- };
104
- }
105
- const results = await searchRelatedCommits(db, query, limit, activeFile);
261
+ if (request.params.name === "build_context_pack") {
262
+ const limit = Number(request.params.arguments?.limit ?? 20);
263
+ const domain = String(request.params.arguments?.domain ?? "").trim();
264
+ const feature = String(request.params.arguments?.feature ?? "").trim();
265
+ const branch = String(request.params.arguments?.branch ?? "").trim();
266
+ const taskType = String(request.params.arguments?.taskType ?? "").trim() || "general";
267
+ const includeDraft = Boolean(request.params.arguments?.includeDraft);
268
+ const db = openDatabase(dbPath);
269
+ try {
270
+ const pack = buildContextPack(db, {
271
+ domain: domain || undefined,
272
+ feature: feature || undefined,
273
+ branch: branch || undefined,
274
+ taskType,
275
+ includeDraft,
276
+ limit: Number.isFinite(limit) && limit > 0 ? limit : 20,
277
+ });
106
278
  return {
107
- content: [{ type: "text", text: JSON.stringify(results, null, 2) }],
279
+ content: [{ type: "text", text: JSON.stringify(pack, null, 2) }],
108
280
  };
109
281
  }
110
- if (request.params.name === "explain_commit_match") {
111
- const chunkId = String(request.params.arguments?.chunkId ?? "").trim();
112
- if (!chunkId) {
113
- return {
114
- content: [{ type: "text", text: "chunkId is required" }],
115
- isError: true,
116
- };
117
- }
118
- const result = explainCommitMatch(db, chunkId);
282
+ finally {
283
+ db.close();
284
+ }
285
+ }
286
+ if (request.params.name === "promote_context_facts") {
287
+ const domain = String(request.params.arguments?.domain ?? "").trim();
288
+ const feature = String(request.params.arguments?.feature ?? "").trim();
289
+ const branch = String(request.params.arguments?.branch ?? "").trim();
290
+ const sourceType = String(request.params.arguments?.sourceType ?? "").trim();
291
+ const db = openDatabase(dbPath);
292
+ try {
293
+ const promotedCount = promoteContextFacts(db, {
294
+ domain: domain || undefined,
295
+ feature: feature || undefined,
296
+ branch: branch || undefined,
297
+ sourceType: sourceType || undefined,
298
+ });
299
+ return {
300
+ content: [
301
+ {
302
+ type: "text",
303
+ text: JSON.stringify({ promotedCount }, null, 2),
304
+ },
305
+ ],
306
+ };
307
+ }
308
+ finally {
309
+ db.close();
310
+ }
311
+ }
312
+ if (request.params.name === "archive_feature_context") {
313
+ const domain = String(request.params.arguments?.domain ?? "").trim();
314
+ const feature = String(request.params.arguments?.feature ?? "").trim();
315
+ if (!domain || !feature) {
119
316
  return {
120
- content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
317
+ content: [{ type: "text", text: "domain and feature are required" }],
318
+ isError: true,
121
319
  };
122
320
  }
123
- if (request.params.name === "get_commit_diff") {
124
- const sha = String(request.params.arguments?.sha ?? "").trim();
125
- if (!sha) {
126
- return {
127
- content: [{ type: "text", text: "sha is required" }],
128
- isError: true,
129
- };
321
+ const db = openDatabase(dbPath);
322
+ try {
323
+ const archivedCount = archiveFeatureContext(db, {
324
+ domain,
325
+ feature,
326
+ });
327
+ return {
328
+ content: [
329
+ {
330
+ type: "text",
331
+ text: JSON.stringify({ archivedCount }, null, 2),
332
+ },
333
+ ],
334
+ };
335
+ }
336
+ finally {
337
+ db.close();
338
+ }
339
+ }
340
+ if (request.params.name === "list_active_worktrees") {
341
+ const baseBranch = String(request.params.arguments?.baseBranch ?? "").trim() || "main";
342
+ const worktrees = listActiveWorktrees(repoPath);
343
+ const db = openDatabase(dbPath);
344
+ try {
345
+ for (const worktree of worktrees) {
346
+ upsertWorktreeSession(db, {
347
+ path: worktree.path,
348
+ branch: worktree.branch,
349
+ baseBranch,
350
+ lastSyncedAt: new Date().toISOString(),
351
+ });
130
352
  }
131
- const output = runGitDiff(repoPath, sha);
353
+ }
354
+ finally {
355
+ db.close();
356
+ }
357
+ return {
358
+ content: [{ type: "text", text: JSON.stringify(worktrees, null, 2) }],
359
+ };
360
+ }
361
+ if (request.params.name === "who_changed_this") {
362
+ const filePath = String(request.params.arguments?.filePath ?? "").trim();
363
+ const limit = Number(request.params.arguments?.limit ?? 20);
364
+ if (!filePath) {
132
365
  return {
133
- content: [{ type: "text", text: output }],
366
+ content: [{ type: "text", text: "filePath is required" }],
367
+ isError: true,
134
368
  };
135
369
  }
370
+ const output = whoChangedFile({
371
+ repoPath,
372
+ filePath,
373
+ limit: Number.isFinite(limit) && limit > 0 ? limit : 20,
374
+ });
136
375
  return {
137
- content: [
138
- { type: "text", text: `Unknown tool: ${request.params.name}` },
139
- ],
140
- isError: true,
376
+ content: [{ type: "text", text: JSON.stringify(output, null, 2) }],
141
377
  };
142
378
  }
143
- finally {
144
- db.close();
379
+ if (request.params.name === "why_was_this_changed") {
380
+ const owner = String(request.params.arguments?.owner ?? "").trim();
381
+ const repo = String(request.params.arguments?.repo ?? "").trim();
382
+ const filePath = String(request.params.arguments?.filePath ?? "").trim();
383
+ const rawSha = String(request.params.arguments?.sha ?? "").trim();
384
+ const sha = rawSha || (filePath ? latestCommitForFile(repoPath, filePath) : null);
385
+ if (!sha) {
386
+ return {
387
+ content: [
388
+ {
389
+ type: "text",
390
+ text: "Provide sha or a filePath that has commit history.",
391
+ },
392
+ ],
393
+ isError: true,
394
+ };
395
+ }
396
+ const commit = commitDetails(repoPath, sha);
397
+ const prNumber = detectReferencedPrNumber(`${commit.subject}\n${commit.body}`);
398
+ let prContext = { pr: null, decisions: [] };
399
+ if (prNumber) {
400
+ const db = openDatabase(dbPath);
401
+ try {
402
+ prContext = loadPullRequestContext(db, prNumber, owner || undefined, repo || undefined);
403
+ }
404
+ finally {
405
+ db.close();
406
+ }
407
+ }
408
+ const result = {
409
+ filePath: filePath || null,
410
+ commit,
411
+ referencedPullRequestNumber: prNumber,
412
+ pullRequest: prContext.pr,
413
+ decisions: prContext.decisions,
414
+ };
415
+ return {
416
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
417
+ };
418
+ }
419
+ if (request.params.name === "get_main_branch_overnight_brief") {
420
+ const baseBranch = String(request.params.arguments?.baseBranch ?? "").trim() || "main";
421
+ const sinceHours = Number(request.params.arguments?.sinceHours ?? 12);
422
+ const limit = Number(request.params.arguments?.limit ?? defaultLimit);
423
+ fetchRemote(repoPath);
424
+ const brief = mainBranchOvernightBrief({
425
+ repoPath,
426
+ baseBranch,
427
+ sinceHours: Number.isFinite(sinceHours) ? sinceHours : 12,
428
+ limit: Number.isFinite(limit) ? limit : defaultLimit,
429
+ });
430
+ return {
431
+ content: [{ type: "text", text: JSON.stringify(brief, null, 2) }],
432
+ };
433
+ }
434
+ if (request.params.name === "resume_feature_session_brief") {
435
+ const worktreePath = String(request.params.arguments?.worktreePath ?? "").trim() || repoPath;
436
+ const baseBranch = String(request.params.arguments?.baseBranch ?? "").trim() || "main";
437
+ fetchRemote(repoPath);
438
+ const brief = resumeFeatureSessionBrief({
439
+ worktreePath,
440
+ baseBranch,
441
+ });
442
+ const db = openDatabase(dbPath);
443
+ try {
444
+ upsertWorktreeSession(db, {
445
+ path: brief.worktreePath,
446
+ branch: brief.branch,
447
+ baseBranch,
448
+ lastSyncedAt: new Date().toISOString(),
449
+ });
450
+ }
451
+ finally {
452
+ db.close();
453
+ }
454
+ return {
455
+ content: [{ type: "text", text: JSON.stringify(brief, null, 2) }],
456
+ };
457
+ }
458
+ if (request.params.name === "pre_plan_sync_brief") {
459
+ const owner = String(request.params.arguments?.owner ?? "").trim();
460
+ const repo = String(request.params.arguments?.repo ?? "").trim();
461
+ const baseBranch = String(request.params.arguments?.baseBranch ?? "").trim() || "main";
462
+ const worktreePath = String(request.params.arguments?.worktreePath ?? "").trim() || repoPath;
463
+ const filePath = String(request.params.arguments?.filePath ?? "").trim();
464
+ const sinceHours = Number(request.params.arguments?.sinceHours ?? 12);
465
+ const limit = Number(request.params.arguments?.limit ?? 25);
466
+ if (!owner || !repo) {
467
+ return {
468
+ content: [{ type: "text", text: "owner and repo are required" }],
469
+ isError: true,
470
+ };
471
+ }
472
+ fetchRemote(repoPath);
473
+ const syncSummary = await syncPullRequestContext({
474
+ repoPath,
475
+ dbPath,
476
+ repoOwner: owner,
477
+ repoName: repo,
478
+ limit,
479
+ });
480
+ const overnight = mainBranchOvernightBrief({
481
+ repoPath,
482
+ baseBranch,
483
+ sinceHours: Number.isFinite(sinceHours) ? sinceHours : 12,
484
+ limit,
485
+ });
486
+ const resume = resumeFeatureSessionBrief({
487
+ worktreePath,
488
+ baseBranch,
489
+ });
490
+ const fileFocus = filePath
491
+ ? whoChangedFile({
492
+ repoPath,
493
+ filePath,
494
+ limit: 10,
495
+ })
496
+ : null;
497
+ const prePlan = {
498
+ syncSummary,
499
+ overnight,
500
+ resume,
501
+ fileFocus,
502
+ recommendations: [
503
+ "Review blocker-level decisions from synced PR context first.",
504
+ "Rebase or merge main if behind is non-zero before coding.",
505
+ "Resolve overlap files before expanding feature scope.",
506
+ ],
507
+ };
508
+ return {
509
+ content: [{ type: "text", text: JSON.stringify(prePlan, null, 2) }],
510
+ };
145
511
  }
512
+ return {
513
+ content: [{ type: "text", text: `Unknown tool: ${request.params.name}` }],
514
+ isError: true,
515
+ };
146
516
  });
147
517
  const transport = new StdioServerTransport();
148
518
  await server.connect(transport);
@@ -0,0 +1,13 @@
1
+ import type { PullRequestSyncSummary } from "../types.js";
2
+ export declare function syncPullRequestContext(options: {
3
+ repoPath: string;
4
+ dbPath: string;
5
+ repoOwner: string;
6
+ repoName: string;
7
+ prNumbers?: number[];
8
+ limit?: number;
9
+ domain?: string;
10
+ feature?: string;
11
+ branch?: string;
12
+ taskType?: string;
13
+ }): Promise<PullRequestSyncSummary>;