@winci/local-rag 0.2.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.
Files changed (61) hide show
  1. package/.claude-plugin/plugin.json +24 -0
  2. package/.mcp.json +11 -0
  3. package/LICENSE +21 -0
  4. package/README.md +567 -0
  5. package/hooks/hooks.json +25 -0
  6. package/hooks/scripts/reindex-file.sh +19 -0
  7. package/hooks/scripts/session-start.sh +11 -0
  8. package/package.json +52 -0
  9. package/skills/local-rag/SKILL.md +42 -0
  10. package/src/cli/commands/analytics.ts +58 -0
  11. package/src/cli/commands/benchmark.ts +30 -0
  12. package/src/cli/commands/checkpoint.ts +85 -0
  13. package/src/cli/commands/conversation.ts +102 -0
  14. package/src/cli/commands/demo.ts +119 -0
  15. package/src/cli/commands/eval.ts +31 -0
  16. package/src/cli/commands/index-cmd.ts +26 -0
  17. package/src/cli/commands/init.ts +35 -0
  18. package/src/cli/commands/map.ts +21 -0
  19. package/src/cli/commands/remove.ts +15 -0
  20. package/src/cli/commands/search-cmd.ts +59 -0
  21. package/src/cli/commands/serve.ts +5 -0
  22. package/src/cli/commands/status.ts +13 -0
  23. package/src/cli/index.ts +117 -0
  24. package/src/cli/progress.ts +21 -0
  25. package/src/cli/setup.ts +192 -0
  26. package/src/config/index.ts +101 -0
  27. package/src/conversation/indexer.ts +147 -0
  28. package/src/conversation/parser.ts +323 -0
  29. package/src/db/analytics.ts +116 -0
  30. package/src/db/annotations.ts +161 -0
  31. package/src/db/checkpoints.ts +166 -0
  32. package/src/db/conversation.ts +241 -0
  33. package/src/db/files.ts +146 -0
  34. package/src/db/graph.ts +250 -0
  35. package/src/db/index.ts +468 -0
  36. package/src/db/search.ts +244 -0
  37. package/src/db/types.ts +85 -0
  38. package/src/embeddings/embed.ts +73 -0
  39. package/src/graph/resolver.ts +305 -0
  40. package/src/indexing/chunker.ts +523 -0
  41. package/src/indexing/indexer.ts +263 -0
  42. package/src/indexing/parse.ts +99 -0
  43. package/src/indexing/watcher.ts +84 -0
  44. package/src/main.ts +8 -0
  45. package/src/search/benchmark.ts +139 -0
  46. package/src/search/eval.ts +171 -0
  47. package/src/search/hybrid.ts +194 -0
  48. package/src/search/reranker.ts +99 -0
  49. package/src/search/usages.ts +27 -0
  50. package/src/server/index.ts +126 -0
  51. package/src/tools/analytics-tools.ts +58 -0
  52. package/src/tools/annotation-tools.ts +89 -0
  53. package/src/tools/checkpoint-tools.ts +147 -0
  54. package/src/tools/conversation-tools.ts +86 -0
  55. package/src/tools/git-tools.ts +103 -0
  56. package/src/tools/graph-tools.ts +163 -0
  57. package/src/tools/index-tools.ts +91 -0
  58. package/src/tools/index.ts +33 -0
  59. package/src/tools/search.ts +238 -0
  60. package/src/types.ts +9 -0
  61. package/src/utils/log.ts +39 -0
@@ -0,0 +1,89 @@
1
+ import { z } from "zod";
2
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { type AnnotationRow } from "../db";
4
+ import { embed } from "../embeddings/embed";
5
+ import { type GetDB, resolveProject } from "./index";
6
+
7
+ export function registerAnnotationTools(server: McpServer, getDB: GetDB) {
8
+ server.tool(
9
+ "annotate",
10
+ "Attach a persistent note to a file or specific symbol. Notes survive sessions and surface inline in read_relevant results. Use for: known issues, caveats, architectural decisions tied to specific code, or 'don't change this until X lands'. Calling again with the same path+symbol updates the existing note.",
11
+ {
12
+ path: z.string().describe("File path (relative to project root) the note applies to"),
13
+ note: z.string().describe("The note text"),
14
+ symbol: z
15
+ .string()
16
+ .optional()
17
+ .describe("Symbol name (function, class, etc.) the note applies to — omit for file-level notes"),
18
+ author: z
19
+ .string()
20
+ .optional()
21
+ .describe("Label for who wrote the note — e.g. 'agent', 'human' (default: 'agent')"),
22
+ directory: z
23
+ .string()
24
+ .optional()
25
+ .describe("Project directory. Defaults to RAG_PROJECT_DIR env or cwd"),
26
+ },
27
+ async ({ path, note, symbol, author, directory }) => {
28
+ const { db: ragDb } = await resolveProject(directory, getDB);
29
+
30
+ const embText = symbol ? `${symbol}: ${note}` : note;
31
+ const embedding = await embed(embText);
32
+ const id = ragDb.upsertAnnotation(path, note, embedding, symbol ?? null, author ?? "agent");
33
+
34
+ const target = symbol ? `${path} • ${symbol}` : path;
35
+ return {
36
+ content: [{ type: "text" as const, text: `Annotation #${id} saved for ${target}` }],
37
+ };
38
+ }
39
+ );
40
+
41
+ server.tool(
42
+ "get_annotations",
43
+ "Retrieve persistent notes attached to files or symbols. Pass path to get all notes for a file. Pass query to search semantically across all annotations. Pass both to filter by file and rank by relevance.",
44
+ {
45
+ path: z
46
+ .string()
47
+ .optional()
48
+ .describe("File path to retrieve annotations for"),
49
+ query: z
50
+ .string()
51
+ .optional()
52
+ .describe("Semantic search query — finds annotations by meaning across all files"),
53
+ directory: z
54
+ .string()
55
+ .optional()
56
+ .describe("Project directory. Defaults to RAG_PROJECT_DIR env or cwd"),
57
+ },
58
+ async ({ path, query, directory }) => {
59
+ const { db: ragDb } = await resolveProject(directory, getDB);
60
+
61
+ let results: AnnotationRow[];
62
+ if (query) {
63
+ const embedding = await embed(query);
64
+ const searchResults = ragDb.searchAnnotations(embedding, 10);
65
+ results = path ? searchResults.filter((r) => r.path === path) : searchResults;
66
+ } else if (path) {
67
+ results = ragDb.getAnnotations(path);
68
+ } else {
69
+ results = ragDb.getAnnotations();
70
+ }
71
+
72
+ if (results.length === 0) {
73
+ return {
74
+ content: [{ type: "text" as const, text: "No annotations found." }],
75
+ };
76
+ }
77
+
78
+ const text = results
79
+ .map((r) => {
80
+ const target = r.symbolName ? `${r.path} • ${r.symbolName}` : r.path;
81
+ const authorStr = r.author ? ` [${r.author}]` : "";
82
+ return `#${r.id} ${target}${authorStr}\n ${r.note}\n (${r.updatedAt})`;
83
+ })
84
+ .join("\n\n");
85
+
86
+ return { content: [{ type: "text" as const, text }] };
87
+ }
88
+ );
89
+ }
@@ -0,0 +1,147 @@
1
+ import { z } from "zod";
2
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { embed } from "../embeddings/embed";
4
+ import { discoverSessions } from "../conversation/parser";
5
+ import { type GetDB, resolveProject } from "./index";
6
+
7
+ export function registerCheckpointTools(server: McpServer, getDB: GetDB) {
8
+ server.tool(
9
+ "create_checkpoint",
10
+ "Create a named checkpoint marking an important moment. Call this liberally: after completing any feature or task, after adding or modifying tools, after key technical decisions, before and after large refactors, when hitting a blocker, or when changing direction. Checkpoints are the only way future sessions can know what was done and why — if in doubt, create one.",
11
+ {
12
+ type: z
13
+ .enum(["decision", "milestone", "blocker", "direction_change", "handoff"])
14
+ .describe("Type of checkpoint"),
15
+ title: z.string().describe("Short label, e.g. 'Chose JWT over session cookies'"),
16
+ summary: z
17
+ .string()
18
+ .describe("2-3 sentence description of what happened and why"),
19
+ filesInvolved: z
20
+ .array(z.string())
21
+ .optional()
22
+ .describe("Files relevant to this checkpoint"),
23
+ tags: z
24
+ .array(z.string())
25
+ .optional()
26
+ .describe("Freeform tags for filtering"),
27
+ directory: z
28
+ .string()
29
+ .optional()
30
+ .describe("Project directory. Defaults to RAG_PROJECT_DIR env or cwd"),
31
+ },
32
+ async ({ type, title, summary, filesInvolved, tags, directory }) => {
33
+ const { projectDir, db: ragDb } = await resolveProject(directory, getDB);
34
+
35
+ // Get current session's latest turn index
36
+ const sessions = discoverSessions(projectDir);
37
+ const sessionId = sessions.length > 0 ? sessions[0].sessionId : "unknown";
38
+
39
+ // Determine turn index from DB
40
+ const turnCount = ragDb.getTurnCount(sessionId);
41
+ const turnIndex = Math.max(0, turnCount - 1);
42
+
43
+ // Embed title + summary for semantic search
44
+ const embText = `${title}. ${summary}`;
45
+ const embedding = await embed(embText);
46
+
47
+ const id = ragDb.createCheckpoint(
48
+ sessionId,
49
+ turnIndex,
50
+ new Date().toISOString(),
51
+ type,
52
+ title,
53
+ summary,
54
+ filesInvolved ?? [],
55
+ tags ?? [],
56
+ embedding
57
+ );
58
+
59
+ return {
60
+ content: [{
61
+ type: "text" as const,
62
+ text: `Checkpoint #${id} created: [${type}] ${title}`,
63
+ }],
64
+ };
65
+ }
66
+ );
67
+
68
+ server.tool(
69
+ "list_checkpoints",
70
+ "List conversation checkpoints, most recent first. Cross-session by default.",
71
+ {
72
+ sessionId: z.string().optional().describe("Limit to a specific session ID"),
73
+ type: z
74
+ .enum(["decision", "milestone", "blocker", "direction_change", "handoff"])
75
+ .optional()
76
+ .describe("Filter by checkpoint type"),
77
+ limit: z.number().optional().default(20).describe("Max results (default: 20)"),
78
+ directory: z
79
+ .string()
80
+ .optional()
81
+ .describe("Project directory. Defaults to RAG_PROJECT_DIR env or cwd"),
82
+ },
83
+ async ({ sessionId, type, limit, directory }) => {
84
+ const { projectDir, db: ragDb } = await resolveProject(directory, getDB);
85
+
86
+ const checkpoints = ragDb.listCheckpoints(sessionId, type, limit);
87
+
88
+ if (checkpoints.length === 0) {
89
+ return {
90
+ content: [{ type: "text" as const, text: "No checkpoints found." }],
91
+ };
92
+ }
93
+
94
+ const text = checkpoints
95
+ .map((cp) => {
96
+ const files = cp.filesInvolved.length > 0
97
+ ? `\n Files: ${cp.filesInvolved.join(", ")}`
98
+ : "";
99
+ const tagStr = cp.tags.length > 0 ? ` [${cp.tags.join(", ")}]` : "";
100
+ return `#${cp.id} [${cp.type}] ${cp.title}${tagStr}\n ${cp.timestamp} (turn ${cp.turnIndex})\n ${cp.summary}${files}`;
101
+ })
102
+ .join("\n\n");
103
+
104
+ return { content: [{ type: "text" as const, text }] };
105
+ }
106
+ );
107
+
108
+ server.tool(
109
+ "search_checkpoints",
110
+ "Semantic search over checkpoint titles and summaries.",
111
+ {
112
+ query: z.string().describe("What to search for in checkpoints"),
113
+ type: z
114
+ .enum(["decision", "milestone", "blocker", "direction_change", "handoff"])
115
+ .optional()
116
+ .describe("Filter by checkpoint type"),
117
+ limit: z.number().optional().default(5).describe("Max results (default: 5)"),
118
+ directory: z
119
+ .string()
120
+ .optional()
121
+ .describe("Project directory. Defaults to RAG_PROJECT_DIR env or cwd"),
122
+ },
123
+ async ({ query, type, limit, directory }) => {
124
+ const { projectDir, db: ragDb } = await resolveProject(directory, getDB);
125
+
126
+ const queryEmb = await embed(query);
127
+ const results = ragDb.searchCheckpoints(queryEmb, limit, type);
128
+
129
+ if (results.length === 0) {
130
+ return {
131
+ content: [{ type: "text" as const, text: "No matching checkpoints found." }],
132
+ };
133
+ }
134
+
135
+ const text = results
136
+ .map((cp) => {
137
+ const files = cp.filesInvolved.length > 0
138
+ ? `\n Files: ${cp.filesInvolved.join(", ")}`
139
+ : "";
140
+ return `${cp.score.toFixed(4)} #${cp.id} [${cp.type}] ${cp.title}\n ${cp.summary}${files}`;
141
+ })
142
+ .join("\n\n");
143
+
144
+ return { content: [{ type: "text" as const, text }] };
145
+ }
146
+ );
147
+ }
@@ -0,0 +1,86 @@
1
+ import { z } from "zod";
2
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { embed } from "../embeddings/embed";
4
+ import { log } from "../utils/log";
5
+ import { type GetDB, resolveProject } from "./index";
6
+
7
+ export function registerConversationTools(server: McpServer, getDB: GetDB) {
8
+ server.tool(
9
+ "search_conversation",
10
+ "Search through conversation history. Finds past decisions, discussions, and tool outputs from current or previous sessions.",
11
+ {
12
+ query: z.string().describe("What to search for in conversation history"),
13
+ directory: z
14
+ .string()
15
+ .optional()
16
+ .describe("Project directory. Defaults to RAG_PROJECT_DIR env or cwd"),
17
+ sessionId: z
18
+ .string()
19
+ .optional()
20
+ .describe("Limit search to a specific session ID. Omit to search all sessions."),
21
+ top: z
22
+ .number()
23
+ .optional()
24
+ .default(5)
25
+ .describe("Number of results to return (default: 5)"),
26
+ },
27
+ async ({ query, directory, sessionId, top }) => {
28
+ const { db: ragDb, config } = await resolveProject(directory, getDB);
29
+
30
+ // Hybrid search: vector + BM25
31
+ const queryEmb = await embed(query);
32
+ const vecResults = ragDb.searchConversation(queryEmb, top, sessionId);
33
+
34
+ let bm25Results: typeof vecResults = [];
35
+ try {
36
+ bm25Results = ragDb.textSearchConversation(query, top, sessionId);
37
+ } catch (err) {
38
+ log.debug(`Conversation FTS query failed, falling back to vector-only: ${err instanceof Error ? err.message : err}`, "conversation");
39
+ }
40
+
41
+ // Merge and deduplicate by turnId using hybrid scoring
42
+ const { hybridWeight } = config;
43
+ const scoreMap = new Map<number, { item: (typeof vecResults)[0]; vecScore: number; txtScore: number }>();
44
+
45
+ for (const r of vecResults) {
46
+ scoreMap.set(r.turnId, { item: r, vecScore: r.score, txtScore: 0 });
47
+ }
48
+ for (const r of bm25Results) {
49
+ const existing = scoreMap.get(r.turnId);
50
+ if (existing) {
51
+ existing.txtScore = r.score;
52
+ } else {
53
+ scoreMap.set(r.turnId, { item: r, vecScore: 0, txtScore: r.score });
54
+ }
55
+ }
56
+
57
+ const results = [...scoreMap.values()]
58
+ .map((e) => ({ ...e.item, score: hybridWeight * e.vecScore + (1 - hybridWeight) * e.txtScore }))
59
+ .sort((a, b) => b.score - a.score)
60
+ .slice(0, top);
61
+
62
+ if (results.length === 0) {
63
+ return {
64
+ content: [{
65
+ type: "text" as const,
66
+ text: "No conversation results found. The conversation may not be indexed yet.",
67
+ }],
68
+ };
69
+ }
70
+
71
+ const text = results
72
+ .map((r) => {
73
+ const tools = r.toolsUsed.length > 0 ? ` [${r.toolsUsed.join(", ")}]` : "";
74
+ const files = r.filesReferenced.length > 0
75
+ ? `\n Files: ${r.filesReferenced.slice(0, 5).join(", ")}`
76
+ : "";
77
+ return `Turn ${r.turnIndex} (${r.timestamp})${tools}\n ${r.snippet.slice(0, 200)}...${files}`;
78
+ })
79
+ .join("\n\n");
80
+
81
+ return {
82
+ content: [{ type: "text" as const, text }],
83
+ };
84
+ }
85
+ );
86
+ }
@@ -0,0 +1,103 @@
1
+ import { z } from "zod";
2
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { resolve } from "path";
4
+ import { type GetDB, resolveProject } from "./index";
5
+
6
+ async function runGit(args: string[], cwd: string): Promise<string | null> {
7
+ try {
8
+ const proc = Bun.spawn(["git", ...args], { cwd, stdout: "pipe", stderr: "pipe" });
9
+ const output = await new Response(proc.stdout).text();
10
+ const exitCode = await proc.exited;
11
+ return exitCode === 0 ? output.trim() : null;
12
+ } catch {
13
+ return null;
14
+ }
15
+ }
16
+
17
+ async function findGitRoot(dir: string): Promise<string | null> {
18
+ return runGit(["rev-parse", "--show-toplevel"], dir);
19
+ }
20
+
21
+ export function registerGitTools(server: McpServer, getDB: GetDB) {
22
+ server.tool(
23
+ "git_context",
24
+ "Show git context for the working tree: uncommitted changes annotated with index status, recent commits, and changed files. Use this at the start of a session to understand what has already been modified before searching or editing.",
25
+ {
26
+ since: z
27
+ .string()
28
+ .optional()
29
+ .describe("Commit ref, branch, or ISO date to look back to (default: HEAD~5)"),
30
+ include_diff: z
31
+ .boolean()
32
+ .optional()
33
+ .describe("Include full unified diff of uncommitted changes, truncated to 200 lines (default: false)"),
34
+ files_only: z
35
+ .boolean()
36
+ .optional()
37
+ .describe("Return file paths only — omit commit messages and diff body (default: false)"),
38
+ directory: z
39
+ .string()
40
+ .optional()
41
+ .describe("Project directory. Defaults to RAG_PROJECT_DIR env or cwd"),
42
+ },
43
+ async ({ since, include_diff, files_only, directory }) => {
44
+ const { projectDir, db: ragDb } = await resolveProject(directory, getDB);
45
+
46
+ const gitRoot = await findGitRoot(resolve(projectDir));
47
+ if (!gitRoot) {
48
+ return { content: [{ type: "text" as const, text: "Not a git repository." }] };
49
+ }
50
+
51
+ const sinceRef = since ?? "HEAD~5";
52
+ const sections: string[] = [];
53
+
54
+ // 1. Uncommitted changes
55
+ const statusOutput = await runGit(["status", "--short"], gitRoot);
56
+ if (statusOutput) {
57
+ const statusLines = statusOutput.split("\n").filter(Boolean);
58
+ if (statusLines.length > 0) {
59
+ const annotated = statusLines.map((line) => {
60
+ const filePart = line.slice(3).trim();
61
+ const filePath = filePart.includes(" -> ") ? filePart.split(" -> ")[1] : filePart;
62
+ const absPath = resolve(gitRoot, filePath);
63
+ const tag = ragDb.getFileByPath(absPath) != null ? "[indexed]" : "[not indexed]";
64
+ return files_only ? `${filePath} ${tag}` : `${line} ${tag}`;
65
+ });
66
+ sections.push("## Uncommitted changes\n" + annotated.join("\n"));
67
+ }
68
+ }
69
+
70
+ // 2. Recent commits (omit body when files_only)
71
+ if (!files_only) {
72
+ const logOutput = await runGit(["log", "--oneline", `${sinceRef}..HEAD`], gitRoot);
73
+ if (logOutput) {
74
+ sections.push(`## Recent commits (since ${sinceRef})\n` + logOutput);
75
+ }
76
+ }
77
+
78
+ // 3. Changed files since sinceRef
79
+ const diffFilesOutput = await runGit(["diff", "--name-only", `${sinceRef}..HEAD`], gitRoot);
80
+ if (diffFilesOutput) {
81
+ sections.push(`## Changed files (since ${sinceRef})\n` + diffFilesOutput);
82
+ }
83
+
84
+ // 4. Diff (opt-in, truncated to 200 lines)
85
+ if (include_diff && !files_only) {
86
+ const diffOutput = await runGit(["diff", "HEAD"], gitRoot);
87
+ if (diffOutput) {
88
+ const diffLines = diffOutput.split("\n");
89
+ const truncated = diffLines.length > 200;
90
+ const body = diffLines.slice(0, 200).join("\n");
91
+ sections.push("## Diff\n" + body + (truncated ? "\n[truncated]" : ""));
92
+ }
93
+ }
94
+
95
+ const text =
96
+ sections.length > 0
97
+ ? sections.join("\n\n")
98
+ : "Nothing to report (clean working tree, no recent commits in range).";
99
+
100
+ return { content: [{ type: "text" as const, text }] };
101
+ }
102
+ );
103
+ }
@@ -0,0 +1,163 @@
1
+ import { z } from "zod";
2
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { resolve, relative } from "path";
4
+ import { generateProjectMap } from "../graph/resolver";
5
+ import { type GetDB, resolveProject } from "./index";
6
+
7
+ export function registerGraphTools(server: McpServer, getDB: GetDB) {
8
+ server.tool(
9
+ "project_map",
10
+ "Generate a structured dependency map of the project. Shows files, their exports, depends_on (imports), and depended_on_by (importers). Entry points are listed separately.",
11
+ {
12
+ directory: z
13
+ .string()
14
+ .optional()
15
+ .describe("Project directory. Defaults to RAG_PROJECT_DIR env or cwd"),
16
+ focus: z
17
+ .string()
18
+ .optional()
19
+ .describe("File path (relative to project) to focus on — shows only nearby files"),
20
+ zoom: z
21
+ .enum(["file", "directory"])
22
+ .optional()
23
+ .describe("Zoom level: 'file' (default) or 'directory' for large projects"),
24
+ maxNodes: z
25
+ .number()
26
+ .optional()
27
+ .describe("Max nodes in graph (default: 50, auto-switches to directory view if exceeded)"),
28
+ },
29
+ async ({ directory, focus, zoom, maxNodes }) => {
30
+ const { projectDir, db: ragDb } = await resolveProject(directory, getDB);
31
+
32
+ const map = generateProjectMap(ragDb, {
33
+ projectDir,
34
+ focus,
35
+ zoom: zoom ?? "file",
36
+ maxNodes: maxNodes ?? 50,
37
+ });
38
+
39
+ return {
40
+ content: [{ type: "text" as const, text: map }],
41
+ };
42
+ }
43
+ );
44
+
45
+ server.tool(
46
+ "find_usages",
47
+ "Find every usage (call site or reference) of a symbol across the codebase. Returns file paths, line numbers, and the matching line. Excludes the file that defines the symbol. Use this before renaming or changing a function signature to understand the blast radius.",
48
+ {
49
+ symbol: z.string().describe("Symbol name to search for"),
50
+ exact: z
51
+ .boolean()
52
+ .optional()
53
+ .describe("Require exact word-boundary match (default: true). Set false for prefix/substring matching."),
54
+ directory: z
55
+ .string()
56
+ .optional()
57
+ .describe("Project directory. Defaults to RAG_PROJECT_DIR env or cwd"),
58
+ top: z.number().optional().describe("Max results to return (default: 30)"),
59
+ },
60
+ async ({ symbol, exact, directory, top }) => {
61
+ const { projectDir, db: ragDb } = await resolveProject(directory, getDB);
62
+
63
+ const results = ragDb.findUsages(symbol, exact ?? true, top ?? 30);
64
+
65
+ if (results.length === 0) {
66
+ return {
67
+ content: [{ type: "text" as const, text: `No usages of "${symbol}" found. The symbol may only appear in its definition file, or the index may need re-running.` }],
68
+ };
69
+ }
70
+
71
+ // Group by file
72
+ const byFile = new Map<string, { line: number | null; snippet: string }[]>();
73
+ for (const r of results) {
74
+ if (!byFile.has(r.path)) byFile.set(r.path, []);
75
+ byFile.get(r.path)!.push({ line: r.line, snippet: r.snippet });
76
+ }
77
+
78
+ const fileCount = byFile.size;
79
+ const lines: string[] = [
80
+ `Found ${results.length} usage${results.length !== 1 ? "s" : ""} of "${symbol}" across ${fileCount} file${fileCount !== 1 ? "s" : ""}:\n`,
81
+ ];
82
+
83
+ for (const [path, usages] of byFile) {
84
+ lines.push(path);
85
+ for (const u of usages) {
86
+ const lineStr = u.line != null ? `:${u.line}` : "";
87
+ lines.push(` ${lineStr} ${u.snippet}`);
88
+ }
89
+ lines.push("");
90
+ }
91
+
92
+ return {
93
+ content: [{ type: "text" as const, text: lines.join("\n") }],
94
+ };
95
+ }
96
+ );
97
+
98
+ server.tool(
99
+ "depends_on",
100
+ "List all files that a given file imports (its dependencies). Returns resolved file paths only — unresolved or external imports are excluded.",
101
+ {
102
+ file: z.string().describe("File path (relative to project) to query"),
103
+ directory: z
104
+ .string()
105
+ .optional()
106
+ .describe("Project directory. Defaults to RAG_PROJECT_DIR env or cwd"),
107
+ },
108
+ async ({ file, directory }) => {
109
+ const { projectDir, db: ragDb } = await resolveProject(directory, getDB);
110
+
111
+ const absPath = resolve(projectDir, file);
112
+ const fileRecord = ragDb.getFileByPath(absPath);
113
+ if (!fileRecord) {
114
+ return { content: [{ type: "text" as const, text: `File "${file}" not found in index.` }] };
115
+ }
116
+
117
+ const deps = ragDb.getDependsOn(fileRecord.id);
118
+ if (deps.length === 0) {
119
+ return { content: [{ type: "text" as const, text: `${file} has no indexed dependencies.` }] };
120
+ }
121
+
122
+ const lines = [`${file} depends on ${deps.length} file${deps.length !== 1 ? "s" : ""}:\n`];
123
+ for (const dep of deps) {
124
+ lines.push(` ${relative(projectDir, dep.path)} (import: ${dep.source})`);
125
+ }
126
+
127
+ return { content: [{ type: "text" as const, text: lines.join("\n") }] };
128
+ }
129
+ );
130
+
131
+ server.tool(
132
+ "depended_on_by",
133
+ "List all files that import a given file (its reverse dependencies / importers). Use this to understand the blast radius before modifying a file.",
134
+ {
135
+ file: z.string().describe("File path (relative to project) to query"),
136
+ directory: z
137
+ .string()
138
+ .optional()
139
+ .describe("Project directory. Defaults to RAG_PROJECT_DIR env or cwd"),
140
+ },
141
+ async ({ file, directory }) => {
142
+ const { projectDir, db: ragDb } = await resolveProject(directory, getDB);
143
+
144
+ const absPath = resolve(projectDir, file);
145
+ const fileRecord = ragDb.getFileByPath(absPath);
146
+ if (!fileRecord) {
147
+ return { content: [{ type: "text" as const, text: `File "${file}" not found in index.` }] };
148
+ }
149
+
150
+ const importers = ragDb.getDependedOnBy(fileRecord.id);
151
+ if (importers.length === 0) {
152
+ return { content: [{ type: "text" as const, text: `No files import ${file}.` }] };
153
+ }
154
+
155
+ const lines = [`${file} is imported by ${importers.length} file${importers.length !== 1 ? "s" : ""}:\n`];
156
+ for (const imp of importers) {
157
+ lines.push(` ${relative(projectDir, imp.path)} (import: ${imp.source})`);
158
+ }
159
+
160
+ return { content: [{ type: "text" as const, text: lines.join("\n") }] };
161
+ }
162
+ );
163
+ }
@@ -0,0 +1,91 @@
1
+ import { z } from "zod";
2
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { indexDirectory } from "../indexing/indexer";
4
+ import { type GetDB, resolveProject } from "./index";
5
+
6
+ export function registerIndexTools(server: McpServer, getDB: GetDB) {
7
+ server.tool(
8
+ "index_files",
9
+ "Index files in a directory for semantic search. Skips unchanged files and prunes deleted ones.",
10
+ {
11
+ directory: z
12
+ .string()
13
+ .optional()
14
+ .describe(
15
+ "Directory to index. Defaults to RAG_PROJECT_DIR env or cwd"
16
+ ),
17
+ patterns: z
18
+ .array(z.string())
19
+ .optional()
20
+ .describe(
21
+ "Override include patterns (e.g. ['**/*.md', '**/*.ts']). Uses .rag/config.json if not provided"
22
+ ),
23
+ },
24
+ async ({ directory, patterns }) => {
25
+ const { projectDir, db: ragDb, config: baseConfig } = await resolveProject(directory, getDB);
26
+ const config = patterns ? { ...baseConfig, include: patterns } : baseConfig;
27
+
28
+ const result = await indexDirectory(projectDir, ragDb, config);
29
+
30
+ return {
31
+ content: [
32
+ {
33
+ type: "text" as const,
34
+ text: `Indexing complete:\n Indexed: ${result.indexed}\n Skipped (unchanged): ${result.skipped}\n Pruned (deleted): ${result.pruned}${result.errors.length > 0 ? `\n Errors: ${result.errors.join("; ")}` : ""}`,
35
+ },
36
+ ],
37
+ };
38
+ }
39
+ );
40
+
41
+ server.tool(
42
+ "index_status",
43
+ "Show the current state of the RAG index for a project directory.",
44
+ {
45
+ directory: z
46
+ .string()
47
+ .optional()
48
+ .describe("Project directory. Defaults to RAG_PROJECT_DIR env or cwd"),
49
+ },
50
+ async ({ directory }) => {
51
+ const { db: ragDb } = await resolveProject(directory, getDB);
52
+ const status = ragDb.getStatus();
53
+
54
+ return {
55
+ content: [
56
+ {
57
+ type: "text" as const,
58
+ text: `Index status:\n Files: ${status.totalFiles}\n Chunks: ${status.totalChunks}\n Last indexed: ${status.lastIndexed || "never"}`,
59
+ },
60
+ ],
61
+ };
62
+ }
63
+ );
64
+
65
+ server.tool(
66
+ "remove_file",
67
+ "Remove a specific file from the RAG index.",
68
+ {
69
+ path: z.string().describe("Absolute path of the file to remove"),
70
+ directory: z
71
+ .string()
72
+ .optional()
73
+ .describe("Project directory. Defaults to RAG_PROJECT_DIR env or cwd"),
74
+ },
75
+ async ({ path, directory }) => {
76
+ const { db: ragDb } = await resolveProject(directory, getDB);
77
+ const removed = ragDb.removeFile(path);
78
+
79
+ return {
80
+ content: [
81
+ {
82
+ type: "text" as const,
83
+ text: removed
84
+ ? `Removed ${path} from index`
85
+ : `${path} was not in the index`,
86
+ },
87
+ ],
88
+ };
89
+ }
90
+ );
91
+ }