@jussmor/commit-memory-mcp 0.3.9 → 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.
@@ -60,6 +60,28 @@ export declare function mainBranchOvernightBrief(options: {
60
60
  subject: string;
61
61
  }>;
62
62
  };
63
+ export declare function extractFeatureBranchCommits(options: {
64
+ repoPath: string;
65
+ featureBranch: string;
66
+ baseBranch: string;
67
+ limit: number;
68
+ }): {
69
+ featureName: string;
70
+ featureBranch: string;
71
+ baseBranch: string;
72
+ commits: Array<{
73
+ sha: string;
74
+ author: string;
75
+ date: string;
76
+ subject: string;
77
+ files: string[];
78
+ }>;
79
+ topFiles: Array<{
80
+ filePath: string;
81
+ touchCount: number;
82
+ }>;
83
+ affectedModules: string[];
84
+ };
63
85
  export declare function resumeFeatureSessionBrief(options: {
64
86
  worktreePath: string;
65
87
  baseBranch: string;
@@ -154,6 +154,85 @@ export function mainBranchOvernightBrief(options) {
154
154
  commits,
155
155
  };
156
156
  }
157
+ export function extractFeatureBranchCommits(options) {
158
+ const repoPath = path.resolve(options.repoPath);
159
+ const featureName = options.featureBranch
160
+ .replace(/^feature\//, "")
161
+ .replace(/[^a-zA-Z0-9-_]/g, "-");
162
+ const limit = Math.max(1, options.limit);
163
+ let commitsRaw;
164
+ try {
165
+ commitsRaw = runGit(repoPath, [
166
+ "log",
167
+ `--format=%H%x1f%an%x1f%aI%x1f%s`,
168
+ `-n${limit}`,
169
+ `${options.baseBranch}..${options.featureBranch}`,
170
+ ]).trim();
171
+ }
172
+ catch {
173
+ return {
174
+ featureName,
175
+ featureBranch: options.featureBranch,
176
+ baseBranch: options.baseBranch,
177
+ commits: [],
178
+ topFiles: [],
179
+ affectedModules: [],
180
+ };
181
+ }
182
+ if (!commitsRaw) {
183
+ return {
184
+ featureName,
185
+ featureBranch: options.featureBranch,
186
+ baseBranch: options.baseBranch,
187
+ commits: [],
188
+ topFiles: [],
189
+ affectedModules: [],
190
+ };
191
+ }
192
+ const fileCounts = new Map();
193
+ const commits = commitsRaw.split("\n").map((line) => {
194
+ const [sha = "", author = "", date = "", subject = ""] = line.split("\x1f");
195
+ let files = [];
196
+ try {
197
+ const filesRaw = runGit(repoPath, [
198
+ "show",
199
+ "--name-only",
200
+ "--pretty=format:",
201
+ sha,
202
+ ]).trim();
203
+ files = filesRaw
204
+ .split("\n")
205
+ .map((f) => f.trim())
206
+ .filter(Boolean);
207
+ }
208
+ catch {
209
+ // ignore per-commit failures
210
+ }
211
+ for (const f of files) {
212
+ fileCounts.set(f, (fileCounts.get(f) ?? 0) + 1);
213
+ }
214
+ return { sha, author, date, subject, files };
215
+ });
216
+ const topFiles = Array.from(fileCounts.entries())
217
+ .map(([filePath, touchCount]) => ({ filePath, touchCount }))
218
+ .sort((a, b) => b.touchCount - a.touchCount)
219
+ .slice(0, 15);
220
+ const moduleSet = new Set();
221
+ for (const { filePath } of topFiles) {
222
+ const parts = filePath.split("/");
223
+ if (parts.length >= 2) {
224
+ moduleSet.add(parts.slice(0, 2).join("/"));
225
+ }
226
+ }
227
+ return {
228
+ featureName,
229
+ featureBranch: options.featureBranch,
230
+ baseBranch: options.baseBranch,
231
+ commits,
232
+ topFiles,
233
+ affectedModules: Array.from(moduleSet).slice(0, 10),
234
+ };
235
+ }
157
236
  export function resumeFeatureSessionBrief(options) {
158
237
  const worktreePath = path.resolve(options.worktreePath);
159
238
  const branch = runGit(worktreePath, [
@@ -6,10 +6,14 @@ 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 { archiveFeatureContext, buildContextPack, openDatabase, promoteContextFacts, upsertWorktreeSession, } from "../db/client.js";
10
- import { commitDetails, explainPathActivity, latestCommitForFile, mainBranchOvernightBrief, resumeFeatureSessionBrief, whoChangedFile, } from "../git/insights.js";
9
+ import { archiveFeatureContext, buildContextPack, openDatabase, promoteContextFacts, upsertContextFact, upsertWorktreeSession, } from "../db/client.js";
10
+ import { commitDetails, explainPathActivity, extractFeatureBranchCommits, 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
+ import { callOllamaLlm } from "../search/embeddings.js";
14
+ function normalizeFeatureName(branch) {
15
+ return branch.replace(/^feature\//, "").replace(/[^a-zA-Z0-9-_]/g, "-");
16
+ }
13
17
  function fetchRemote(repoPath) {
14
18
  execFileSync("git", ["-C", repoPath, "fetch", "--all", "--prune"], {
15
19
  stdio: ["ignore", "pipe", "pipe"],
@@ -287,6 +291,41 @@ export async function startMcpServer() {
287
291
  required: [],
288
292
  },
289
293
  },
294
+ {
295
+ name: "learn_feature",
296
+ description: "Save feature knowledge to the RAG DB. If agentContent is provided (recommended), it is stored directly as the feature's understanding — the agent should investigate the source code itself and pass its findings here. If agentContent is omitted, falls back to git-commit-based inference.",
297
+ inputSchema: {
298
+ type: "object",
299
+ properties: {
300
+ featureBranch: {
301
+ type: "string",
302
+ description: "e.g. feature/messaging",
303
+ },
304
+ baseBranch: { type: "string" },
305
+ limit: { type: "number" },
306
+ agentContent: {
307
+ type: "string",
308
+ description: "Plain-text description of what the feature does, written by the agent after reading actual source files. When provided, this becomes the stored knowledge (confidence 0.95) and git metadata is appended as supporting context.",
309
+ },
310
+ },
311
+ required: ["featureBranch"],
312
+ },
313
+ },
314
+ {
315
+ name: "sync_feature_knowledge",
316
+ description: "Update the AI knowledge for a feature branch using new commits and PR decisions since the last sync. Bootstraps automatically if no prior knowledge exists.",
317
+ inputSchema: {
318
+ type: "object",
319
+ properties: {
320
+ featureBranch: { type: "string" },
321
+ baseBranch: { type: "string" },
322
+ owner: { type: "string" },
323
+ repo: { type: "string" },
324
+ limit: { type: "number" },
325
+ },
326
+ required: ["featureBranch"],
327
+ },
328
+ },
290
329
  {
291
330
  name: "pre_plan_sync_brief",
292
331
  description: "Run sync + overnight + feature resume analysis before planning work.",
@@ -641,6 +680,320 @@ export async function startMcpServer() {
641
680
  content: [{ type: "text", text: JSON.stringify(prePlan, null, 2) }],
642
681
  };
643
682
  }
683
+ if (request.params.name === "learn_feature") {
684
+ const featureBranch = String(request.params.arguments?.featureBranch ?? "").trim();
685
+ const baseBranch = String(request.params.arguments?.baseBranch ?? "").trim() || "main";
686
+ const limit = Number(request.params.arguments?.limit ?? 50);
687
+ const agentContent = String(request.params.arguments?.agentContent ?? "").trim() || null;
688
+ if (!featureBranch) {
689
+ return {
690
+ content: [{ type: "text", text: "featureBranch is required" }],
691
+ isError: true,
692
+ };
693
+ }
694
+ const featureName = normalizeFeatureName(featureBranch);
695
+ const data = extractFeatureBranchCommits({
696
+ repoPath,
697
+ featureBranch,
698
+ baseBranch,
699
+ limit: Number.isFinite(limit) && limit > 0 ? limit : 50,
700
+ });
701
+ const authors = [...new Set(data.commits.map((c) => c.author))].join(", ");
702
+ const now = new Date().toISOString();
703
+ let content;
704
+ let confidence;
705
+ let aiGenerated;
706
+ if (agentContent) {
707
+ // Agent investigated the source code directly — highest confidence.
708
+ // Append git metadata as supporting context.
709
+ const gitMeta = [
710
+ ``,
711
+ `--- Git metadata (${data.commits.length} commits, authors: ${authors || "unknown"}) ---`,
712
+ `Top files: ${data.topFiles
713
+ .slice(0, 5)
714
+ .map((f) => f.filePath)
715
+ .join(", ") || "(none)"}`,
716
+ `Top modules: ${data.affectedModules.slice(0, 4).join(", ") || "(none)"}`,
717
+ ].join("\n");
718
+ content = agentContent + gitMeta;
719
+ confidence = 0.95;
720
+ aiGenerated = true;
721
+ }
722
+ else {
723
+ // Fallback: infer from git commits + optional Ollama synthesis.
724
+ const fileList = data.topFiles
725
+ .map((f) => ` - ${f.filePath} (touched ${f.touchCount}x)`)
726
+ .join("\n");
727
+ const commitList = data.commits
728
+ .slice(0, 20)
729
+ .map((c) => ` - ${c.subject} (${c.author})`)
730
+ .join("\n");
731
+ const prompt = [
732
+ `You are analyzing a Git feature branch called "${featureBranch}".`,
733
+ ``,
734
+ `Top changed files:`,
735
+ fileList || " (none)",
736
+ ``,
737
+ `Commit history (most recent first):`,
738
+ commitList || " (none)",
739
+ ``,
740
+ `Answer in 3-5 sentences:`,
741
+ `1. What does this feature do?`,
742
+ `2. What modules/areas of the codebase does it affect?`,
743
+ `3. What does it NOT do or what is explicitly out of scope?`,
744
+ ].join("\n");
745
+ const llmSummary = await callOllamaLlm(prompt);
746
+ const fallbackSummary = [
747
+ `Feature "${featureName}" spans ${data.commits.length} commit(s) by ${authors || "(unknown)"}.`,
748
+ `Top modules: ${data.affectedModules.slice(0, 4).join(", ") || "(unknown)"}.`,
749
+ `Top files: ${data.topFiles
750
+ .slice(0, 3)
751
+ .map((f) => f.filePath)
752
+ .join(", ") || "(none)"}.`,
753
+ `Commit subjects: ${data.commits
754
+ .slice(0, 5)
755
+ .map((c) => c.subject)
756
+ .join("; ")}.`,
757
+ ].join(" ");
758
+ content = llmSummary ?? fallbackSummary;
759
+ confidence = llmSummary ? 0.85 : 0.6;
760
+ aiGenerated = llmSummary !== null;
761
+ }
762
+ const db = openDatabase(dbPath);
763
+ try {
764
+ upsertContextFact(db, {
765
+ id: `feature-knowledge:${featureName}`,
766
+ sourceType: "feature-agent",
767
+ sourceRef: featureBranch,
768
+ domain: "",
769
+ feature: featureName,
770
+ branch: featureBranch,
771
+ taskType: "feature-knowledge",
772
+ title: `Feature knowledge: ${featureName}`,
773
+ content,
774
+ priority: 0.9,
775
+ confidence,
776
+ status: "promoted",
777
+ createdAt: now,
778
+ updatedAt: now,
779
+ });
780
+ }
781
+ finally {
782
+ db.close();
783
+ }
784
+ return {
785
+ content: [
786
+ {
787
+ type: "text",
788
+ text: JSON.stringify({
789
+ featureName,
790
+ learned: content,
791
+ filesAnalyzed: data.topFiles.length,
792
+ commitsAnalyzed: data.commits.length,
793
+ agentProvided: agentContent !== null,
794
+ aiGenerated,
795
+ confidence,
796
+ savedAt: now,
797
+ }, null, 2),
798
+ },
799
+ ],
800
+ };
801
+ }
802
+ if (request.params.name === "sync_feature_knowledge") {
803
+ const featureBranch = String(request.params.arguments?.featureBranch ?? "").trim();
804
+ const baseBranch = String(request.params.arguments?.baseBranch ?? "").trim() || "main";
805
+ const owner = String(request.params.arguments?.owner ?? "").trim();
806
+ const repo = String(request.params.arguments?.repo ?? "").trim();
807
+ const limit = Number(request.params.arguments?.limit ?? 50);
808
+ if (!featureBranch) {
809
+ return {
810
+ content: [{ type: "text", text: "featureBranch is required" }],
811
+ isError: true,
812
+ };
813
+ }
814
+ const featureName = normalizeFeatureName(featureBranch);
815
+ const safeLimit = Number.isFinite(limit) && limit > 0 ? limit : 50;
816
+ // Load existing knowledge from DB
817
+ let existingKnowledge = null;
818
+ let existingUpdatedAt = null;
819
+ let existingCreatedAt = null;
820
+ const dbRead = openDatabase(dbPath);
821
+ try {
822
+ const existing = dbRead
823
+ .prepare("SELECT content, created_at, updated_at FROM context_facts WHERE id = ? LIMIT 1")
824
+ .get(`feature-knowledge:${featureName}`);
825
+ existingKnowledge = existing?.content ?? null;
826
+ existingUpdatedAt = existing?.updated_at ?? null;
827
+ existingCreatedAt = existing?.created_at ?? null;
828
+ }
829
+ finally {
830
+ dbRead.close();
831
+ }
832
+ // Bootstrap via learn_feature logic if no prior knowledge
833
+ if (!existingKnowledge) {
834
+ const bootData = extractFeatureBranchCommits({
835
+ repoPath,
836
+ featureBranch,
837
+ baseBranch,
838
+ limit: safeLimit,
839
+ });
840
+ const bootFileList = bootData.topFiles
841
+ .map((f) => ` - ${f.filePath} (touched ${f.touchCount}x)`)
842
+ .join("\n");
843
+ const bootCommitList = bootData.commits
844
+ .slice(0, 20)
845
+ .map((c) => ` - ${c.subject} (${c.author})`)
846
+ .join("\n");
847
+ const bootPrompt = [
848
+ `You are analyzing a Git feature branch called "${featureBranch}".`,
849
+ `Top changed files:\n${bootFileList || " (none)"}`,
850
+ `Commit history:\n${bootCommitList || " (none)"}`,
851
+ `Answer in 3-5 sentences: 1. What does this feature do? 2. What modules does it affect? 3. What is out of scope?`,
852
+ ].join("\n\n");
853
+ const bootLlm = await callOllamaLlm(bootPrompt);
854
+ existingKnowledge =
855
+ bootLlm ??
856
+ `Feature "${featureName}" has ${bootData.commits.length} commit(s). Top modules: ${bootData.affectedModules.slice(0, 4).join(", ") || "(unknown)"}.`;
857
+ const now = new Date().toISOString();
858
+ const dbBoot = openDatabase(dbPath);
859
+ try {
860
+ upsertContextFact(dbBoot, {
861
+ id: `feature-knowledge:${featureName}`,
862
+ sourceType: "feature-agent",
863
+ sourceRef: featureBranch,
864
+ domain: "",
865
+ feature: featureName,
866
+ branch: featureBranch,
867
+ taskType: "feature-knowledge",
868
+ title: `Feature knowledge: ${featureName}`,
869
+ content: existingKnowledge,
870
+ priority: 0.9,
871
+ confidence: bootLlm ? 0.85 : 0.6,
872
+ status: "promoted",
873
+ createdAt: now,
874
+ updatedAt: now,
875
+ });
876
+ }
877
+ finally {
878
+ dbBoot.close();
879
+ }
880
+ existingUpdatedAt = now;
881
+ existingCreatedAt = now;
882
+ }
883
+ // Fetch remote and extract commits
884
+ fetchRemote(repoPath);
885
+ const data = extractFeatureBranchCommits({
886
+ repoPath,
887
+ featureBranch,
888
+ baseBranch,
889
+ limit: safeLimit,
890
+ });
891
+ // Only process commits newer than last sync
892
+ const newCommits = existingUpdatedAt
893
+ ? data.commits.filter((c) => c.date > existingUpdatedAt)
894
+ : data.commits;
895
+ // Gather PR decisions for referenced PRs in new commits
896
+ const referencedPrNumbers = newCommits
897
+ .map((c) => detectReferencedPrNumber(c.subject))
898
+ .filter((n) => n !== null);
899
+ let prDecisionsSummary = "";
900
+ if (referencedPrNumbers.length > 0) {
901
+ const dbPr = openDatabase(dbPath);
902
+ try {
903
+ const parts = [];
904
+ for (const prNum of referencedPrNumbers.slice(0, 5)) {
905
+ const { pr, decisions } = loadPullRequestContext(dbPr, prNum, owner || undefined, repo || undefined);
906
+ if (pr) {
907
+ parts.push(`PR #${prNum} "${pr["title"]}": ${decisions
908
+ .slice(0, 3)
909
+ .map((d) => d["summary"])
910
+ .join("; ")}`);
911
+ }
912
+ }
913
+ prDecisionsSummary = parts.join("\n");
914
+ }
915
+ finally {
916
+ dbPr.close();
917
+ }
918
+ }
919
+ const newCommitList = newCommits
920
+ .slice(0, 20)
921
+ .map((c) => ` - ${c.subject} (${c.author})`)
922
+ .join("\n");
923
+ const updatePrompt = [
924
+ `You previously documented this feature:`,
925
+ `"${existingKnowledge}"`,
926
+ ``,
927
+ `New commits since last sync:`,
928
+ newCommitList || " (no new commits)",
929
+ prDecisionsSummary ? `\nPR decisions:\n${prDecisionsSummary}` : "",
930
+ ``,
931
+ `Write an updated 3-5 sentence understanding of the feature. If nothing changed, return the previous text unchanged.`,
932
+ ].join("\n");
933
+ const updatedSummary = newCommits.length > 0
934
+ ? ((await callOllamaLlm(updatePrompt)) ?? existingKnowledge)
935
+ : existingKnowledge;
936
+ const now = new Date().toISOString();
937
+ const dbWrite = openDatabase(dbPath);
938
+ try {
939
+ upsertContextFact(dbWrite, {
940
+ id: `feature-knowledge:${featureName}`,
941
+ sourceType: "feature-agent",
942
+ sourceRef: featureBranch,
943
+ domain: "",
944
+ feature: featureName,
945
+ branch: featureBranch,
946
+ taskType: "feature-knowledge",
947
+ title: `Feature knowledge: ${featureName}`,
948
+ content: updatedSummary,
949
+ priority: 0.9,
950
+ confidence: 0.85,
951
+ status: "promoted",
952
+ createdAt: existingCreatedAt ?? now,
953
+ updatedAt: now,
954
+ });
955
+ // Audit log row (status: draft — invisible to default build_context_pack)
956
+ if (newCommits.length > 0) {
957
+ const auditDate = now.split("T")[0] ?? now;
958
+ upsertContextFact(dbWrite, {
959
+ id: `feature-change-log:${featureName}:${auditDate}`,
960
+ sourceType: "feature-agent",
961
+ sourceRef: featureBranch,
962
+ domain: "",
963
+ feature: featureName,
964
+ branch: featureBranch,
965
+ taskType: "change-log",
966
+ title: `Change log: ${featureName} on ${auditDate}`,
967
+ content: `New commits: ${newCommits
968
+ .map((c) => c.subject)
969
+ .join("; ")}. ${prDecisionsSummary}`.trim(),
970
+ priority: 0.7,
971
+ confidence: 0.8,
972
+ status: "draft",
973
+ createdAt: now,
974
+ updatedAt: now,
975
+ });
976
+ }
977
+ }
978
+ finally {
979
+ dbWrite.close();
980
+ }
981
+ return {
982
+ content: [
983
+ {
984
+ type: "text",
985
+ text: JSON.stringify({
986
+ featureName,
987
+ previousKnowledge: existingKnowledge,
988
+ updatedKnowledge: updatedSummary,
989
+ newCommitsAnalyzed: newCommits.length,
990
+ totalCommitsInBranch: data.commits.length,
991
+ syncedAt: now,
992
+ }, null, 2),
993
+ },
994
+ ],
995
+ };
996
+ }
644
997
  return {
645
998
  content: [{ type: "text", text: `Unknown tool: ${request.params.name}` }],
646
999
  isError: true,
@@ -1,2 +1,3 @@
1
1
  export declare function embedText(text: string): Promise<number[]>;
2
+ export declare function callOllamaLlm(prompt: string): Promise<string | null>;
2
3
  export declare function getExpectedDimension(): number;
@@ -52,6 +52,30 @@ export async function embedText(text) {
52
52
  }
53
53
  return normalize(payload.embedding);
54
54
  }
55
+ export async function callOllamaLlm(prompt) {
56
+ const model = process.env.OLLAMA_CHAT_MODEL;
57
+ if (!model) {
58
+ return null;
59
+ }
60
+ const baseUrl = process.env.OLLAMA_BASE_URL ?? "http://127.0.0.1:11434";
61
+ try {
62
+ const response = await fetch(`${baseUrl}/api/generate`, {
63
+ method: "POST",
64
+ headers: { "Content-Type": "application/json" },
65
+ body: JSON.stringify({ model, prompt, stream: false }),
66
+ });
67
+ if (!response.ok) {
68
+ return null;
69
+ }
70
+ const payload = (await response.json());
71
+ return typeof payload.response === "string" && payload.response.trim()
72
+ ? payload.response.trim()
73
+ : null;
74
+ }
75
+ catch {
76
+ return null;
77
+ }
78
+ }
55
79
  export function getExpectedDimension() {
56
80
  return process.env.OLLAMA_EMBED_MODEL
57
81
  ? Number.parseInt(process.env.COMMIT_RAG_DIMENSION ?? "", 10) ||
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jussmor/commit-memory-mcp",
3
- "version": "0.3.9",
3
+ "version": "0.4.1",
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",