@jussmor/commit-memory-mcp 0.3.6 → 0.3.8

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.md CHANGED
@@ -1,60 +1,242 @@
1
1
  # @jussmor/commit-memory-mcp
2
2
 
3
- Local commit-aware RAG package powered by sqlite-vec with MCP tool endpoints.
3
+ A local MCP server for PR intelligence, author tracing, and worktree-aware async planning.
4
4
 
5
- ## Features
5
+ ## Purpose
6
6
 
7
- - Indexes git commits across branches into commit-file chunks
8
- - Embeds chunks using a local embedding source (Ollama when configured)
9
- - Stores vectors in sqlite-vec (SQLite)
10
- - Exposes MCP tools for agent workflows
7
+ This package helps agents answer:
11
8
 
12
- ## MCP tools
9
+ - who changed a file or area
10
+ - why a change was made
11
+ - what landed on main recently
12
+ - what context should be reviewed before planning
13
+
14
+ ## Current MCP tools
15
+
16
+ - `sync_pr_context`
17
+ - `build_context_pack`
18
+ - `promote_context_facts`
19
+ - `archive_feature_context`
20
+ - `list_active_worktrees`
21
+ - `who_changed_this`
22
+ - `why_was_this_changed`
23
+ - `get_main_branch_overnight_brief`
24
+ - `resume_feature_session_brief`
25
+ - `pre_plan_sync_brief`
26
+
27
+ ## Removed tools
28
+
29
+ The following legacy tools were removed:
13
30
 
14
31
  - `search_related_commits`
15
32
  - `explain_commit_match`
16
33
  - `get_commit_diff`
34
+ - `reindex_commits`
17
35
 
18
36
  ## Quick start
19
37
 
20
38
  ```bash
21
39
  npm install @jussmor/commit-memory-mcp
22
- npx commit-memory-index --repo /path/to/repo --db /path/to/repo/.commit-rag.db --limit 400
23
40
  npx commit-memory-mcp
24
41
  ```
25
42
 
26
- ## Publish
43
+ For local development in this repository:
27
44
 
28
45
  ```bash
46
+ cd packages/commit-rag-mcp
47
+ npm install
29
48
  npm run build
30
- npm publish --access public
31
- mcp-publisher login github
32
- mcp-publisher publish
49
+ node dist/mcp/server.js
50
+ ```
51
+
52
+ ## Requirements
53
+
54
+ - Node.js 20+
55
+ - `gh` CLI authenticated for GitHub PR sync features
56
+ - a git repository available through `COMMIT_RAG_REPO`
57
+
58
+ ## Environment variables
59
+
60
+ - `COMMIT_RAG_REPO` repository path used by the MCP server
61
+ - `COMMIT_RAG_DB` SQLite database path
62
+ - `COMMIT_RAG_LIMIT` default sync/query limit
63
+ - `OLLAMA_BASE_URL` optional Ollama base URL
64
+ - `OLLAMA_EMBED_MODEL` optional embedding model
65
+ - `COPILOT_TOKEN` optional Copilot reranking token
66
+ - `COPILOT_MODEL` optional Copilot model override
67
+ - `COPILOT_BASE_URL` optional Copilot API base URL
68
+
69
+ ## Use cases
70
+
71
+ ### Sync GitHub PR context
72
+
73
+ ```text
74
+ sync_pr_context({
75
+ owner: "MaxwellClinic-Development",
76
+ repo: "EverBetter-Pro",
77
+ domain: "billing",
78
+ feature: "invoice-retry",
79
+ branch: "feat/invoice-retry",
80
+ taskType: "planning",
81
+ limit: 20
82
+ })
33
83
  ```
34
84
 
35
- ## VS Code MCP registration
85
+ Use this before planning when you need fresh PR descriptions, comments, and reviews.
36
86
 
37
- Copy `mcp.config.example.json` entries into your user MCP config and adjust paths/env values.
87
+ ### List active worktrees
38
88
 
39
- For MCP Registry publication, keep `package.json` `mcpName` and `server.json` `name` in sync.
89
+ ```text
90
+ list_active_worktrees({
91
+ baseBranch: "main"
92
+ })
93
+ ```
94
+
95
+ Use this when your team works on multiple features in parallel and wants session-aware context.
40
96
 
41
- ## Environment
97
+ ### Build a scoped context pack
98
+
99
+ ```text
100
+ build_context_pack({
101
+ domain: "billing",
102
+ feature: "invoice-retry",
103
+ branch: "feat/invoice-retry",
104
+ taskType: "coding",
105
+ includeDraft: false,
106
+ limit: 12
107
+ })
108
+ ```
42
109
 
43
- - `COMMIT_RAG_REPO` default repository path for MCP
44
- - `COMMIT_RAG_DB` sqlite db path
45
- - `COMMIT_RAG_LIMIT` max commits to index per run
46
- - `OLLAMA_BASE_URL` local ollama URL (default `http://127.0.0.1:11434`)
47
- - `OLLAMA_EMBED_MODEL` local embedding model name
110
+ Use this before invoking a coding subagent to keep prompts small and focused.
111
+ If no rows are found in strict scope, the server now falls back automatically to broader scope levels.
48
112
 
49
- If `OLLAMA_EMBED_MODEL` is not set, the package uses deterministic local fallback embeddings.
113
+ Important: if you provide `domain`/`feature`/`branch` tags in `build_context_pack`, use the same tags during `sync_pr_context` for best precision.
114
+
115
+ ### Promote draft facts after review
116
+
117
+ ```text
118
+ promote_context_facts({
119
+ domain: "billing",
120
+ feature: "invoice-retry",
121
+ branch: "feat/invoice-retry"
122
+ })
123
+ ```
50
124
 
51
- ### Copilot LLM reranking (optional)
125
+ Use this when discussion outcomes are approved and should become durable context.
52
126
 
53
- Set `COPILOT_TOKEN` to a GitHub token with Copilot access to enable LLM-based reranking.
54
- After initial vector/keyword retrieval, results are sent to Copilot for semantic scoring and re-sorted.
127
+ ### Archive completed feature context
55
128
 
56
- - `COPILOT_TOKEN` GitHub PAT or token with Copilot access (enables reranking)
57
- - `COPILOT_MODEL` model slug (default: `gpt-4o-mini`, supports `claude-sonnet-4-5`, `gpt-4o`, etc.)
58
- - `COPILOT_BASE_URL` API base URL (default: `https://api.githubcopilot.com`)
129
+ ```text
130
+ archive_feature_context({
131
+ domain: "billing",
132
+ feature: "invoice-retry"
133
+ })
134
+ ```
135
+
136
+ Use this after merge/closure to prevent active context bloat.
137
+
138
+ ### Find ownership for a file
139
+
140
+ ```text
141
+ who_changed_this({
142
+ filePath: "src/features/auth/session.ts",
143
+ limit: 15
144
+ })
145
+ ```
146
+
147
+ Use this to discover recent authors and commit history for a target file.
148
+
149
+ ### Explain what is happening in a Next.js folder
150
+
151
+ ```text
152
+ explain_path_activity({
153
+ targetPath: "app/dashboard",
154
+ owner: "MaxwellClinic-Development",
155
+ repo: "EverBetter-Pro",
156
+ limit: 30
157
+ })
158
+ ```
159
+
160
+ Use this when you want a fast folder-level summary for areas like `app/dashboard`, `app/(authenticated)`, or `src/components`.
161
+ It returns recent commits, top authors, most touched files, and related PR context/decisions when available.
162
+
163
+ ### Explain intent for a change
164
+
165
+ ```text
166
+ why_was_this_changed({
167
+ filePath: "src/features/auth/session.ts",
168
+ owner: "MaxwellClinic-Development",
169
+ repo: "EverBetter-Pro"
170
+ })
171
+ ```
172
+
173
+ Use this to combine commit metadata with synced PR context.
174
+
175
+ ### Get an overnight main-branch brief
176
+
177
+ ```text
178
+ get_main_branch_overnight_brief({
179
+ baseBranch: "main",
180
+ sinceHours: 12,
181
+ limit: 25
182
+ })
183
+ ```
184
+
185
+ Use this at the start of the day to review what landed while you were offline.
186
+
187
+ ### Resume a feature worktree
188
+
189
+ ```text
190
+ resume_feature_session_brief({
191
+ worktreePath: "/path/to/worktree",
192
+ baseBranch: "main"
193
+ })
194
+ ```
195
+
196
+ Use this before resuming unfinished work in a separate worktree.
197
+
198
+ ### Run the full pre-plan sync flow
199
+
200
+ ```text
201
+ pre_plan_sync_brief({
202
+ owner: "MaxwellClinic-Development",
203
+ repo: "EverBetter-Pro",
204
+ baseBranch: "main",
205
+ worktreePath: "/path/to/worktree",
206
+ sinceHours: 12,
207
+ limit: 25
208
+ })
209
+ ```
210
+
211
+ Use this as the default entrypoint for async team planning.
212
+
213
+ ## Multi-session git worktree flow
214
+
215
+ For parallel AI coding sessions:
216
+
217
+ 1. Create one git worktree per feature branch.
218
+ 2. Use `list_active_worktrees` to enumerate active sessions.
219
+ 3. Use `resume_feature_session_brief` per worktree to check divergence and overlap risks.
220
+ 4. Generate a worktree-specific `build_context_pack` and hand it to the target subagent.
221
+
222
+ This pattern avoids one giant shared context and scales better as features grow.
223
+
224
+ ## Data model overview
225
+
226
+ The package stores local context for:
227
+
228
+ - commits
229
+ - pull requests
230
+ - PR comments
231
+ - PR reviews
232
+ - promoted decision/blocker summaries
233
+ - worktree session checkpoints
234
+
235
+ ## Publishing
236
+
237
+ ```bash
238
+ npm run build
239
+ npm publish --access public
240
+ ```
59
241
 
60
- Reranking works alongside or instead of Ollama no embedding model required.
242
+ For MCP Registry publication, keep `package.json` `mcpName` and `server.json` `name` aligned.
@@ -1,5 +1,5 @@
1
1
  import Database from "better-sqlite3";
2
- import type { CommitChunk, PullRequestCommentRecord, PullRequestDecisionRecord, PullRequestRecord, PullRequestReviewRecord, WorktreeSessionRecord } from "../types.js";
2
+ import type { CommitChunk, ContextFactRecord, ContextPackRecord, PullRequestCommentRecord, PullRequestDecisionRecord, PullRequestRecord, PullRequestReviewRecord, WorktreeSessionRecord } from "../types.js";
3
3
  export type RagDatabase = Database.Database;
4
4
  export declare function openDatabase(dbPath: string): RagDatabase;
5
5
  export declare function hasChunk(db: RagDatabase, chunkId: string): boolean;
@@ -11,3 +11,22 @@ export declare function replacePullRequestReviews(db: RagDatabase, repoOwner: st
11
11
  export declare function replacePullRequestDecisions(db: RagDatabase, repoOwner: string, repoName: string, prNumber: number, decisions: PullRequestDecisionRecord[]): void;
12
12
  export declare function touchPullRequestSyncState(db: RagDatabase, repoOwner: string, repoName: string): void;
13
13
  export declare function upsertWorktreeSession(db: RagDatabase, session: WorktreeSessionRecord): void;
14
+ export declare function upsertContextFact(db: RagDatabase, fact: ContextFactRecord): void;
15
+ export declare function promoteContextFacts(db: RagDatabase, options: {
16
+ domain?: string;
17
+ feature?: string;
18
+ branch?: string;
19
+ sourceType?: string;
20
+ }): number;
21
+ export declare function buildContextPack(db: RagDatabase, options: {
22
+ domain?: string;
23
+ feature?: string;
24
+ branch?: string;
25
+ taskType?: string;
26
+ includeDraft?: boolean;
27
+ limit: number;
28
+ }): ContextPackRecord[];
29
+ export declare function archiveFeatureContext(db: RagDatabase, options: {
30
+ domain: string;
31
+ feature: string;
32
+ }): number;
package/dist/db/client.js CHANGED
@@ -109,6 +109,44 @@ export function openDatabase(dbPath) {
109
109
  base_branch TEXT NOT NULL,
110
110
  last_synced_at TEXT NOT NULL
111
111
  );
112
+
113
+ CREATE TABLE IF NOT EXISTS context_facts (
114
+ id TEXT PRIMARY KEY,
115
+ source_type TEXT NOT NULL,
116
+ source_ref TEXT NOT NULL,
117
+ scope_domain TEXT NOT NULL,
118
+ scope_feature TEXT NOT NULL,
119
+ scope_branch TEXT NOT NULL,
120
+ scope_task_type TEXT NOT NULL,
121
+ title TEXT NOT NULL,
122
+ content TEXT NOT NULL,
123
+ priority REAL NOT NULL,
124
+ confidence REAL NOT NULL,
125
+ status TEXT NOT NULL,
126
+ created_at TEXT NOT NULL,
127
+ updated_at TEXT NOT NULL
128
+ );
129
+
130
+ CREATE INDEX IF NOT EXISTS idx_context_scope
131
+ ON context_facts(scope_domain, scope_feature, scope_branch, scope_task_type, status, updated_at);
132
+
133
+ CREATE TABLE IF NOT EXISTS context_fact_archive (
134
+ id TEXT PRIMARY KEY,
135
+ source_type TEXT NOT NULL,
136
+ source_ref TEXT NOT NULL,
137
+ scope_domain TEXT NOT NULL,
138
+ scope_feature TEXT NOT NULL,
139
+ scope_branch TEXT NOT NULL,
140
+ scope_task_type TEXT NOT NULL,
141
+ title TEXT NOT NULL,
142
+ content TEXT NOT NULL,
143
+ priority REAL NOT NULL,
144
+ confidence REAL NOT NULL,
145
+ status TEXT NOT NULL,
146
+ created_at TEXT NOT NULL,
147
+ updated_at TEXT NOT NULL,
148
+ archived_at TEXT NOT NULL
149
+ );
112
150
  `);
113
151
  return db;
114
152
  }
@@ -270,3 +308,211 @@ export function upsertWorktreeSession(db, session) {
270
308
  last_synced_at = excluded.last_synced_at
271
309
  `).run(session.path, session.branch, session.baseBranch, session.lastSyncedAt);
272
310
  }
311
+ export function upsertContextFact(db, fact) {
312
+ db.prepare(`
313
+ INSERT INTO context_facts (
314
+ id,
315
+ source_type,
316
+ source_ref,
317
+ scope_domain,
318
+ scope_feature,
319
+ scope_branch,
320
+ scope_task_type,
321
+ title,
322
+ content,
323
+ priority,
324
+ confidence,
325
+ status,
326
+ created_at,
327
+ updated_at
328
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
329
+ ON CONFLICT(id) DO UPDATE SET
330
+ source_type = excluded.source_type,
331
+ source_ref = excluded.source_ref,
332
+ scope_domain = excluded.scope_domain,
333
+ scope_feature = excluded.scope_feature,
334
+ scope_branch = excluded.scope_branch,
335
+ scope_task_type = excluded.scope_task_type,
336
+ title = excluded.title,
337
+ content = excluded.content,
338
+ priority = excluded.priority,
339
+ confidence = excluded.confidence,
340
+ status = excluded.status,
341
+ created_at = excluded.created_at,
342
+ updated_at = excluded.updated_at
343
+ `).run(fact.id, fact.sourceType, fact.sourceRef, fact.domain, fact.feature, fact.branch, fact.taskType, fact.title, fact.content, fact.priority, fact.confidence, fact.status, fact.createdAt, fact.updatedAt);
344
+ }
345
+ export function promoteContextFacts(db, options) {
346
+ const clauses = ["status = 'draft'"];
347
+ const params = [];
348
+ if (options.domain) {
349
+ clauses.push("scope_domain = ?");
350
+ params.push(options.domain);
351
+ }
352
+ if (options.feature) {
353
+ clauses.push("scope_feature = ?");
354
+ params.push(options.feature);
355
+ }
356
+ if (options.branch) {
357
+ clauses.push("scope_branch = ?");
358
+ params.push(options.branch);
359
+ }
360
+ if (options.sourceType) {
361
+ clauses.push("source_type = ?");
362
+ params.push(options.sourceType);
363
+ }
364
+ const sql = `
365
+ UPDATE context_facts
366
+ SET status = 'promoted', updated_at = ?
367
+ WHERE ${clauses.join(" AND ")}
368
+ `;
369
+ const now = new Date().toISOString();
370
+ const result = db.prepare(sql).run(now, ...params);
371
+ return Number(result.changes ?? 0);
372
+ }
373
+ export function buildContextPack(db, options) {
374
+ const taskType = options.taskType ?? "general";
375
+ function runQuery(params) {
376
+ const clauses = [];
377
+ const values = [taskType, taskType];
378
+ if (params.includeDomain && options.domain) {
379
+ clauses.push("scope_domain = ?");
380
+ values.push(options.domain);
381
+ }
382
+ if (params.includeFeature && options.feature) {
383
+ clauses.push("scope_feature = ?");
384
+ values.push(options.feature);
385
+ }
386
+ if (params.includeBranch && options.branch) {
387
+ clauses.push("scope_branch = ?");
388
+ values.push(options.branch);
389
+ }
390
+ if (options.includeDraft) {
391
+ clauses.push("status IN ('promoted', 'draft')");
392
+ }
393
+ else {
394
+ clauses.push("status = 'promoted'");
395
+ }
396
+ const where = clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : "";
397
+ const sql = `
398
+ SELECT
399
+ id,
400
+ source_type,
401
+ source_ref,
402
+ title,
403
+ content,
404
+ scope_domain,
405
+ scope_feature,
406
+ scope_branch,
407
+ scope_task_type,
408
+ priority,
409
+ confidence,
410
+ status,
411
+ updated_at,
412
+ ((priority * 0.40) + (confidence * 0.30) +
413
+ CASE
414
+ WHEN scope_task_type = ? THEN 0.30
415
+ WHEN scope_task_type = 'general' THEN 0.15
416
+ ELSE 0.0
417
+ END +
418
+ CASE
419
+ WHEN source_type = 'pr_description' THEN 0.15
420
+ WHEN source_type LIKE 'pr_%' THEN 0.08
421
+ ELSE 0.0
422
+ END) AS score
423
+ FROM context_facts
424
+ ${where}
425
+ ORDER BY score DESC, updated_at DESC
426
+ LIMIT ?
427
+ `;
428
+ values.push(options.limit);
429
+ const rows = db.prepare(sql).all(...values);
430
+ return rows.map((row) => ({
431
+ id: String(row.id ?? ""),
432
+ sourceType: String(row.source_type ?? ""),
433
+ sourceRef: String(row.source_ref ?? ""),
434
+ title: String(row.title ?? ""),
435
+ content: String(row.content ?? ""),
436
+ domain: String(row.scope_domain ?? ""),
437
+ feature: String(row.scope_feature ?? ""),
438
+ branch: String(row.scope_branch ?? ""),
439
+ taskType: String(row.scope_task_type ?? ""),
440
+ priority: Number(row.priority ?? 0),
441
+ confidence: Number(row.confidence ?? 0),
442
+ score: Number(row.score ?? 0),
443
+ status: String(row.status ?? "promoted"),
444
+ updatedAt: String(row.updated_at ?? ""),
445
+ }));
446
+ }
447
+ const strictRows = runQuery({
448
+ includeDomain: true,
449
+ includeFeature: true,
450
+ includeBranch: true,
451
+ });
452
+ if (strictRows.length > 0) {
453
+ return strictRows;
454
+ }
455
+ const broadRows = runQuery({
456
+ includeDomain: true,
457
+ includeFeature: false,
458
+ includeBranch: false,
459
+ });
460
+ if (broadRows.length > 0) {
461
+ return broadRows;
462
+ }
463
+ return runQuery({
464
+ includeDomain: false,
465
+ includeFeature: false,
466
+ includeBranch: false,
467
+ });
468
+ }
469
+ export function archiveFeatureContext(db, options) {
470
+ const now = new Date().toISOString();
471
+ const tx = db.transaction(() => {
472
+ db.prepare(`
473
+ INSERT OR REPLACE INTO context_fact_archive (
474
+ id,
475
+ source_type,
476
+ source_ref,
477
+ scope_domain,
478
+ scope_feature,
479
+ scope_branch,
480
+ scope_task_type,
481
+ title,
482
+ content,
483
+ priority,
484
+ confidence,
485
+ status,
486
+ created_at,
487
+ updated_at,
488
+ archived_at
489
+ )
490
+ SELECT
491
+ id,
492
+ source_type,
493
+ source_ref,
494
+ scope_domain,
495
+ scope_feature,
496
+ scope_branch,
497
+ scope_task_type,
498
+ title,
499
+ content,
500
+ priority,
501
+ confidence,
502
+ 'archived',
503
+ created_at,
504
+ updated_at,
505
+ ?
506
+ FROM context_facts
507
+ WHERE scope_domain = ? AND scope_feature = ?
508
+ `).run(now, options.domain, options.feature);
509
+ return db
510
+ .prepare(`
511
+ DELETE FROM context_facts
512
+ WHERE scope_domain = ? AND scope_feature = ?
513
+ `)
514
+ .run(options.domain, options.feature);
515
+ });
516
+ const result = tx();
517
+ return Number(result.changes ?? 0);
518
+ }
@@ -16,6 +16,27 @@ export declare function whoChangedFile(options: {
16
16
  lastCommitAt: string;
17
17
  }>;
18
18
  };
19
+ export declare function explainPathActivity(options: {
20
+ repoPath: string;
21
+ targetPath: string;
22
+ limit: number;
23
+ }): {
24
+ targetPath: string;
25
+ commits: Array<{
26
+ sha: string;
27
+ author: string;
28
+ date: string;
29
+ subject: string;
30
+ }>;
31
+ topAuthors: Array<{
32
+ author: string;
33
+ commitCount: number;
34
+ }>;
35
+ topFiles: Array<{
36
+ filePath: string;
37
+ touchCount: number;
38
+ }>;
39
+ };
19
40
  export declare function latestCommitForFile(repoPath: string, filePath: string): string | null;
20
41
  export declare function commitDetails(repoPath: string, sha: string): {
21
42
  sha: string;
@@ -46,6 +46,66 @@ export function whoChangedFile(options) {
46
46
  authors,
47
47
  };
48
48
  }
49
+ export function explainPathActivity(options) {
50
+ const repoPath = path.resolve(options.repoPath);
51
+ const limit = Math.max(1, options.limit);
52
+ const targetPath = options.targetPath.trim();
53
+ const commitsRaw = runGit(repoPath, [
54
+ "log",
55
+ `-n${limit}`,
56
+ "--format=%H%x1f%an%x1f%aI%x1f%s",
57
+ "--",
58
+ targetPath,
59
+ ]).trim();
60
+ if (!commitsRaw) {
61
+ return {
62
+ targetPath,
63
+ commits: [],
64
+ topAuthors: [],
65
+ topFiles: [],
66
+ };
67
+ }
68
+ const commits = commitsRaw.split("\n").map((line) => {
69
+ const [sha = "", author = "", date = "", subject = ""] = line.split("\x1f");
70
+ return { sha, author, date, subject };
71
+ });
72
+ const authorCounts = new Map();
73
+ const fileCounts = new Map();
74
+ for (const commit of commits) {
75
+ authorCounts.set(commit.author, (authorCounts.get(commit.author) ?? 0) + 1);
76
+ const filesRaw = runGit(repoPath, [
77
+ "show",
78
+ "--name-only",
79
+ "--pretty=format:",
80
+ commit.sha,
81
+ "--",
82
+ targetPath,
83
+ ]).trim();
84
+ if (!filesRaw) {
85
+ continue;
86
+ }
87
+ for (const filePath of filesRaw
88
+ .split("\n")
89
+ .map((line) => line.trim())
90
+ .filter(Boolean)) {
91
+ fileCounts.set(filePath, (fileCounts.get(filePath) ?? 0) + 1);
92
+ }
93
+ }
94
+ const topAuthors = Array.from(authorCounts.entries())
95
+ .map(([author, commitCount]) => ({ author, commitCount }))
96
+ .sort((a, b) => b.commitCount - a.commitCount)
97
+ .slice(0, 8);
98
+ const topFiles = Array.from(fileCounts.entries())
99
+ .map(([filePath, touchCount]) => ({ filePath, touchCount }))
100
+ .sort((a, b) => b.touchCount - a.touchCount)
101
+ .slice(0, 12);
102
+ return {
103
+ targetPath,
104
+ commits,
105
+ topAuthors,
106
+ topFiles,
107
+ };
108
+ }
49
109
  export function latestCommitForFile(repoPath, filePath) {
50
110
  const output = runGit(path.resolve(repoPath), [
51
111
  "log",
@@ -6,8 +6,8 @@ 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, upsertWorktreeSession } from "../db/client.js";
10
- import { commitDetails, latestCommitForFile, mainBranchOvernightBrief, resumeFeatureSessionBrief, whoChangedFile, } from "../git/insights.js";
9
+ import { archiveFeatureContext, buildContextPack, openDatabase, promoteContextFacts, upsertWorktreeSession, } from "../db/client.js";
10
+ import { commitDetails, explainPathActivity, latestCommitForFile, mainBranchOvernightBrief, resumeFeatureSessionBrief, whoChangedFile, } from "../git/insights.js";
11
11
  import { listActiveWorktrees } from "../git/worktree.js";
12
12
  import { syncPullRequestContext } from "../pr/sync.js";
13
13
  function fetchRemote(repoPath) {
@@ -26,6 +26,78 @@ function detectReferencedPrNumber(text) {
26
26
  }
27
27
  return value;
28
28
  }
29
+ function parsePrNumberFromSourceRef(sourceRef) {
30
+ const match = sourceRef.match(/#(\d{1,8})\b/);
31
+ if (!match) {
32
+ return null;
33
+ }
34
+ const value = Number.parseInt(match[1] ?? "", 10);
35
+ if (!Number.isFinite(value) || value <= 0) {
36
+ return null;
37
+ }
38
+ return value;
39
+ }
40
+ function loadPathPullRequestContext(db, options) {
41
+ const prNumbers = new Set(options.referencedPrNumbers);
42
+ const likePattern = `%${options.targetPath}%`;
43
+ const sourceRows = db
44
+ .prepare(`
45
+ SELECT source_ref
46
+ FROM context_facts
47
+ WHERE (title LIKE ? OR content LIKE ?)
48
+ AND source_ref LIKE '%#%'
49
+ ORDER BY updated_at DESC
50
+ LIMIT 80
51
+ `)
52
+ .all(likePattern, likePattern);
53
+ for (const row of sourceRows) {
54
+ const number = parsePrNumberFromSourceRef(String(row.source_ref ?? ""));
55
+ if (number) {
56
+ prNumbers.add(number);
57
+ }
58
+ }
59
+ if (prNumbers.size === 0) {
60
+ return [];
61
+ }
62
+ const results = [];
63
+ for (const prNumber of Array.from(prNumbers).slice(0, 20)) {
64
+ const pr = options.owner && options.repo
65
+ ? (db
66
+ .prepare(`
67
+ SELECT repo_owner, repo_name, pr_number, title, body, author, state, created_at, updated_at, merged_at, url
68
+ FROM prs
69
+ WHERE repo_owner = ? AND repo_name = ? AND pr_number = ?
70
+ LIMIT 1
71
+ `)
72
+ .get(options.owner, options.repo, prNumber) ?? null)
73
+ : (db
74
+ .prepare(`
75
+ SELECT repo_owner, repo_name, pr_number, title, body, author, state, created_at, updated_at, merged_at, url
76
+ FROM prs
77
+ WHERE pr_number = ?
78
+ ORDER BY updated_at DESC
79
+ LIMIT 1
80
+ `)
81
+ .get(prNumber) ?? null);
82
+ if (!pr) {
83
+ continue;
84
+ }
85
+ const decisions = db
86
+ .prepare(`
87
+ SELECT id, source, author, summary, severity, created_at
88
+ FROM pr_decisions
89
+ WHERE repo_owner = ? AND repo_name = ? AND pr_number = ?
90
+ ORDER BY created_at DESC
91
+ LIMIT 20
92
+ `)
93
+ .all(pr.repo_owner, pr.repo_name, pr.pr_number);
94
+ results.push({
95
+ pr,
96
+ decisions,
97
+ });
98
+ }
99
+ return results;
100
+ }
29
101
  function loadPullRequestContext(db, prNumber, repoOwner, repoName) {
30
102
  const pr = repoOwner && repoName
31
103
  ? (db
@@ -88,11 +160,57 @@ export async function startMcpServer() {
88
160
  type: "array",
89
161
  items: { type: "number" },
90
162
  },
163
+ domain: { type: "string" },
164
+ feature: { type: "string" },
165
+ branch: { type: "string" },
166
+ taskType: { type: "string" },
91
167
  limit: { type: "number" },
92
168
  },
93
169
  required: ["owner", "repo"],
94
170
  },
95
171
  },
172
+ {
173
+ name: "build_context_pack",
174
+ description: "Build a scoped context pack for a task/domain/feature/branch to keep agent prompts small.",
175
+ inputSchema: {
176
+ type: "object",
177
+ properties: {
178
+ domain: { type: "string" },
179
+ feature: { type: "string" },
180
+ branch: { type: "string" },
181
+ taskType: { type: "string" },
182
+ includeDraft: { type: "boolean" },
183
+ limit: { type: "number" },
184
+ },
185
+ required: [],
186
+ },
187
+ },
188
+ {
189
+ name: "promote_context_facts",
190
+ description: "Promote scoped draft facts into durable promoted context.",
191
+ inputSchema: {
192
+ type: "object",
193
+ properties: {
194
+ domain: { type: "string" },
195
+ feature: { type: "string" },
196
+ branch: { type: "string" },
197
+ sourceType: { type: "string" },
198
+ },
199
+ required: [],
200
+ },
201
+ },
202
+ {
203
+ name: "archive_feature_context",
204
+ description: "Archive all active facts for a domain/feature once work is complete.",
205
+ inputSchema: {
206
+ type: "object",
207
+ properties: {
208
+ domain: { type: "string" },
209
+ feature: { type: "string" },
210
+ },
211
+ required: ["domain", "feature"],
212
+ },
213
+ },
96
214
  {
97
215
  name: "list_active_worktrees",
98
216
  description: "List active git worktrees for multi-session feature work.",
@@ -116,6 +234,20 @@ export async function startMcpServer() {
116
234
  required: ["filePath"],
117
235
  },
118
236
  },
237
+ {
238
+ name: "explain_path_activity",
239
+ description: "Given a file or folder path, summarize activity, top files/authors, and related PR context.",
240
+ inputSchema: {
241
+ type: "object",
242
+ properties: {
243
+ targetPath: { type: "string" },
244
+ owner: { type: "string" },
245
+ repo: { type: "string" },
246
+ limit: { type: "number" },
247
+ },
248
+ required: ["targetPath"],
249
+ },
250
+ },
119
251
  {
120
252
  name: "why_was_this_changed",
121
253
  description: "Explain intent for a commit or file using git history and synced PR decisions.",
@@ -192,6 +324,10 @@ export async function startMcpServer() {
192
324
  .filter((value) => Number.isFinite(value) && value > 0)
193
325
  : undefined;
194
326
  const limit = Number(request.params.arguments?.limit ?? 25);
327
+ const domain = String(request.params.arguments?.domain ?? "").trim();
328
+ const feature = String(request.params.arguments?.feature ?? "").trim();
329
+ const branch = String(request.params.arguments?.branch ?? "").trim();
330
+ const taskType = String(request.params.arguments?.taskType ?? "").trim();
195
331
  const summary = await syncPullRequestContext({
196
332
  repoPath,
197
333
  dbPath,
@@ -199,11 +335,94 @@ export async function startMcpServer() {
199
335
  repoName: repo,
200
336
  prNumbers,
201
337
  limit,
338
+ domain: domain || undefined,
339
+ feature: feature || undefined,
340
+ branch: branch || undefined,
341
+ taskType: taskType || undefined,
202
342
  });
203
343
  return {
204
344
  content: [{ type: "text", text: JSON.stringify(summary, null, 2) }],
205
345
  };
206
346
  }
347
+ if (request.params.name === "build_context_pack") {
348
+ const limit = Number(request.params.arguments?.limit ?? 20);
349
+ const domain = String(request.params.arguments?.domain ?? "").trim();
350
+ const feature = String(request.params.arguments?.feature ?? "").trim();
351
+ const branch = String(request.params.arguments?.branch ?? "").trim();
352
+ const taskType = String(request.params.arguments?.taskType ?? "").trim() || "general";
353
+ const includeDraft = Boolean(request.params.arguments?.includeDraft);
354
+ const db = openDatabase(dbPath);
355
+ try {
356
+ const pack = buildContextPack(db, {
357
+ domain: domain || undefined,
358
+ feature: feature || undefined,
359
+ branch: branch || undefined,
360
+ taskType,
361
+ includeDraft,
362
+ limit: Number.isFinite(limit) && limit > 0 ? limit : 20,
363
+ });
364
+ return {
365
+ content: [{ type: "text", text: JSON.stringify(pack, null, 2) }],
366
+ };
367
+ }
368
+ finally {
369
+ db.close();
370
+ }
371
+ }
372
+ if (request.params.name === "promote_context_facts") {
373
+ const domain = String(request.params.arguments?.domain ?? "").trim();
374
+ const feature = String(request.params.arguments?.feature ?? "").trim();
375
+ const branch = String(request.params.arguments?.branch ?? "").trim();
376
+ const sourceType = String(request.params.arguments?.sourceType ?? "").trim();
377
+ const db = openDatabase(dbPath);
378
+ try {
379
+ const promotedCount = promoteContextFacts(db, {
380
+ domain: domain || undefined,
381
+ feature: feature || undefined,
382
+ branch: branch || undefined,
383
+ sourceType: sourceType || undefined,
384
+ });
385
+ return {
386
+ content: [
387
+ {
388
+ type: "text",
389
+ text: JSON.stringify({ promotedCount }, null, 2),
390
+ },
391
+ ],
392
+ };
393
+ }
394
+ finally {
395
+ db.close();
396
+ }
397
+ }
398
+ if (request.params.name === "archive_feature_context") {
399
+ const domain = String(request.params.arguments?.domain ?? "").trim();
400
+ const feature = String(request.params.arguments?.feature ?? "").trim();
401
+ if (!domain || !feature) {
402
+ return {
403
+ content: [{ type: "text", text: "domain and feature are required" }],
404
+ isError: true,
405
+ };
406
+ }
407
+ const db = openDatabase(dbPath);
408
+ try {
409
+ const archivedCount = archiveFeatureContext(db, {
410
+ domain,
411
+ feature,
412
+ });
413
+ return {
414
+ content: [
415
+ {
416
+ type: "text",
417
+ text: JSON.stringify({ archivedCount }, null, 2),
418
+ },
419
+ ],
420
+ };
421
+ }
422
+ finally {
423
+ db.close();
424
+ }
425
+ }
207
426
  if (request.params.name === "list_active_worktrees") {
208
427
  const baseBranch = String(request.params.arguments?.baseBranch ?? "").trim() || "main";
209
428
  const worktrees = listActiveWorktrees(repoPath);
@@ -243,6 +462,52 @@ export async function startMcpServer() {
243
462
  content: [{ type: "text", text: JSON.stringify(output, null, 2) }],
244
463
  };
245
464
  }
465
+ if (request.params.name === "explain_path_activity") {
466
+ const targetPath = String(request.params.arguments?.targetPath ?? "").trim();
467
+ const owner = String(request.params.arguments?.owner ?? "").trim();
468
+ const repo = String(request.params.arguments?.repo ?? "").trim();
469
+ const limit = Number(request.params.arguments?.limit ?? 25);
470
+ if (!targetPath) {
471
+ return {
472
+ content: [{ type: "text", text: "targetPath is required" }],
473
+ isError: true,
474
+ };
475
+ }
476
+ const output = explainPathActivity({
477
+ repoPath,
478
+ targetPath,
479
+ limit: Number.isFinite(limit) && limit > 0 ? limit : 25,
480
+ });
481
+ const referencedPrNumbers = output.commits
482
+ .map((commit) => {
483
+ const details = commitDetails(repoPath, commit.sha);
484
+ return detectReferencedPrNumber(`${details.subject}\n${details.body}`);
485
+ })
486
+ .filter((value) => Number.isFinite(value));
487
+ const db = openDatabase(dbPath);
488
+ try {
489
+ const relatedPullRequests = loadPathPullRequestContext(db, {
490
+ targetPath,
491
+ owner: owner || undefined,
492
+ repo: repo || undefined,
493
+ referencedPrNumbers,
494
+ });
495
+ return {
496
+ content: [
497
+ {
498
+ type: "text",
499
+ text: JSON.stringify({
500
+ ...output,
501
+ relatedPullRequests,
502
+ }, null, 2),
503
+ },
504
+ ],
505
+ };
506
+ }
507
+ finally {
508
+ db.close();
509
+ }
510
+ }
246
511
  if (request.params.name === "why_was_this_changed") {
247
512
  const owner = String(request.params.arguments?.owner ?? "").trim();
248
513
  const repo = String(request.params.arguments?.repo ?? "").trim();
package/dist/pr/sync.d.ts CHANGED
@@ -6,4 +6,8 @@ export declare function syncPullRequestContext(options: {
6
6
  repoName: string;
7
7
  prNumbers?: number[];
8
8
  limit?: number;
9
+ domain?: string;
10
+ feature?: string;
11
+ branch?: string;
12
+ taskType?: string;
9
13
  }): Promise<PullRequestSyncSummary>;
package/dist/pr/sync.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { execFileSync } from "node:child_process";
2
2
  import path from "node:path";
3
- import { openDatabase, replacePullRequestComments, replacePullRequestDecisions, replacePullRequestReviews, touchPullRequestSyncState, upsertPullRequest, } from "../db/client.js";
3
+ import { openDatabase, replacePullRequestComments, replacePullRequestDecisions, replacePullRequestReviews, touchPullRequestSyncState, upsertContextFact, upsertPullRequest, } from "../db/client.js";
4
4
  function runGh(repoPath, args) {
5
5
  return execFileSync("gh", args, {
6
6
  cwd: repoPath,
@@ -180,6 +180,45 @@ export async function syncPullRequestContext(options) {
180
180
  replacePullRequestComments(db, options.repoOwner, options.repoName, parsed.pr.number, parsed.comments);
181
181
  replacePullRequestReviews(db, options.repoOwner, options.repoName, parsed.pr.number, parsed.reviews);
182
182
  replacePullRequestDecisions(db, options.repoOwner, options.repoName, parsed.pr.number, parsed.decisions);
183
+ const scopeDomain = (options.domain ?? options.repoName).trim();
184
+ const scopeFeature = (options.feature ?? `pr-${parsed.pr.number}`).trim() ||
185
+ `pr-${parsed.pr.number}`;
186
+ const scopeBranch = (options.branch ?? "main").trim() || "main";
187
+ const taskType = (options.taskType ?? "planning").trim() || "planning";
188
+ upsertContextFact(db, {
189
+ id: `pr:${options.repoOwner}/${options.repoName}#${parsed.pr.number}:description`,
190
+ sourceType: "pr_description",
191
+ sourceRef: `${options.repoOwner}/${options.repoName}#${parsed.pr.number}`,
192
+ domain: scopeDomain,
193
+ feature: scopeFeature,
194
+ branch: scopeBranch,
195
+ taskType,
196
+ title: parsed.pr.title,
197
+ content: parsed.pr.body,
198
+ priority: 0.85,
199
+ confidence: 0.9,
200
+ status: "promoted",
201
+ createdAt: parsed.pr.createdAt,
202
+ updatedAt: parsed.pr.updatedAt,
203
+ });
204
+ for (const decision of parsed.decisions) {
205
+ upsertContextFact(db, {
206
+ id: `decision:${options.repoOwner}/${options.repoName}#${parsed.pr.number}:${decision.id}`,
207
+ sourceType: `pr_${decision.source}`,
208
+ sourceRef: `${options.repoOwner}/${options.repoName}#${parsed.pr.number}`,
209
+ domain: scopeDomain,
210
+ feature: scopeFeature,
211
+ branch: scopeBranch,
212
+ taskType,
213
+ title: `Decision ${parsed.pr.number} (${decision.source})`,
214
+ content: decision.summary,
215
+ priority: decision.severity === "blocker" ? 1 : 0.75,
216
+ confidence: 0.8,
217
+ status: decision.source === "description" ? "promoted" : "draft",
218
+ createdAt: decision.createdAt,
219
+ updatedAt: decision.createdAt,
220
+ });
221
+ }
183
222
  syncedPrs += 1;
184
223
  syncedComments += parsed.comments.length;
185
224
  syncedReviews += parsed.reviews.length;
package/dist/types.d.ts CHANGED
@@ -84,3 +84,36 @@ export type WorktreeSessionRecord = {
84
84
  lastSyncedAt: string;
85
85
  baseBranch: string;
86
86
  };
87
+ export type ContextFactStatus = "draft" | "promoted" | "archived";
88
+ export type ContextFactRecord = {
89
+ id: string;
90
+ sourceType: string;
91
+ sourceRef: string;
92
+ domain: string;
93
+ feature: string;
94
+ branch: string;
95
+ taskType: string;
96
+ title: string;
97
+ content: string;
98
+ priority: number;
99
+ confidence: number;
100
+ status: ContextFactStatus;
101
+ createdAt: string;
102
+ updatedAt: string;
103
+ };
104
+ export type ContextPackRecord = {
105
+ id: string;
106
+ sourceType: string;
107
+ sourceRef: string;
108
+ title: string;
109
+ content: string;
110
+ domain: string;
111
+ feature: string;
112
+ branch: string;
113
+ taskType: string;
114
+ priority: number;
115
+ confidence: number;
116
+ score: number;
117
+ status: ContextFactStatus;
118
+ updatedAt: string;
119
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jussmor/commit-memory-mcp",
3
- "version": "0.3.6",
3
+ "version": "0.3.8",
4
4
  "mcpName": "io.github.jussmor/commit-memory",
5
5
  "description": "Commit-aware RAG with sqlite-vec and MCP tools for local agent workflows",
6
6
  "license": "MIT",