mita-code 0.1.0__py3-none-any.whl
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.
- mita/__init__.py +5 -0
- mita/__main__.py +5 -0
- mita/agent/__init__.py +1 -0
- mita/agent/context.py +43 -0
- mita/agent/conversation.py +101 -0
- mita/agent/loop.py +594 -0
- mita/agent/system_prompt.py +75 -0
- mita/cli.py +940 -0
- mita/config/__init__.py +6 -0
- mita/config/defaults.py +41 -0
- mita/config/loader.py +53 -0
- mita/config/schema.py +131 -0
- mita/hooks/__init__.py +1 -0
- mita/hooks/manager.py +94 -0
- mita/hooks/runner.py +145 -0
- mita/index/__init__.py +1 -0
- mita/index/embeddings.py +49 -0
- mita/index/manager.py +170 -0
- mita/index/parser.py +331 -0
- mita/index/retriever.py +53 -0
- mita/index/store.py +143 -0
- mita/llm/__init__.py +1 -0
- mita/llm/client.py +86 -0
- mita/llm/instructor.py +80 -0
- mita/llm/streaming.py +58 -0
- mita/memory/__init__.py +6 -0
- mita/memory/discovery.py +61 -0
- mita/memory/loader.py +76 -0
- mita/memory/manager.py +117 -0
- mita/models/__init__.py +13 -0
- mita/models/hardware.py +289 -0
- mita/models/manager.py +268 -0
- mita/models/ollama_client.py +104 -0
- mita/models/recommender.py +88 -0
- mita/models/registry.py +167 -0
- mita/models/server.py +262 -0
- mita/plugins/__init__.py +1 -0
- mita/plugins/client.py +152 -0
- mita/plugins/manager.py +210 -0
- mita/py.typed +0 -0
- mita/skills/__init__.py +1 -0
- mita/skills/executor.py +84 -0
- mita/skills/loader.py +117 -0
- mita/skills/manager.py +129 -0
- mita/tools/__init__.py +1 -0
- mita/tools/builtins/__init__.py +28 -0
- mita/tools/builtins/file_edit.py +71 -0
- mita/tools/builtins/file_read.py +74 -0
- mita/tools/builtins/file_write.py +42 -0
- mita/tools/builtins/git.py +112 -0
- mita/tools/builtins/glob_tool.py +67 -0
- mita/tools/builtins/grep_tool.py +93 -0
- mita/tools/builtins/shell.py +83 -0
- mita/tools/executor.py +80 -0
- mita/tools/registry.py +69 -0
- mita/tools/safety.py +91 -0
- mita/tools/schema.py +87 -0
- mita/ui/__init__.py +1 -0
- mita/ui/display.py +139 -0
- mita/ui/repl.py +88 -0
- mita/ui/spinner.py +48 -0
- mita/ui/theme.py +23 -0
- mita_code-0.1.0.dist-info/METADATA +227 -0
- mita_code-0.1.0.dist-info/RECORD +67 -0
- mita_code-0.1.0.dist-info/WHEEL +4 -0
- mita_code-0.1.0.dist-info/entry_points.txt +3 -0
- mita_code-0.1.0.dist-info/licenses/LICENSE +201 -0
mita/index/manager.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""CLI command handlers for codebase indexing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
from rich.progress import BarColumn, Progress, SpinnerColumn, TextColumn
|
|
10
|
+
from rich.syntax import Syntax
|
|
11
|
+
from rich.table import Table
|
|
12
|
+
|
|
13
|
+
from mita.config.loader import load_config
|
|
14
|
+
from mita.index.embeddings import EmbeddingClient
|
|
15
|
+
from mita.index.parser import parse_codebase
|
|
16
|
+
from mita.index.retriever import Retriever
|
|
17
|
+
from mita.index.store import IndexStore
|
|
18
|
+
|
|
19
|
+
console = Console()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _get_index_dir() -> Path:
|
|
23
|
+
return Path.cwd() / ".mita" / "index"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
async def build_index(force: bool = False, pull_model_fn: object | None = None) -> None:
|
|
27
|
+
"""Build the codebase index.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
force: Rebuild even if index already exists.
|
|
31
|
+
pull_model_fn: Async callback to pull the embedding model if missing.
|
|
32
|
+
Signature: async (MitaConfig) -> bool. Called from the CLI layer
|
|
33
|
+
so it can import from mita.models without violating dependency rules.
|
|
34
|
+
"""
|
|
35
|
+
config = load_config()
|
|
36
|
+
index_dir = _get_index_dir()
|
|
37
|
+
store = IndexStore(index_dir)
|
|
38
|
+
|
|
39
|
+
# Check if index already exists
|
|
40
|
+
if store.exists() and not force:
|
|
41
|
+
console.print("[yellow]Index already exists. Use --force to rebuild.[/yellow]")
|
|
42
|
+
return
|
|
43
|
+
|
|
44
|
+
# Check embedding model availability
|
|
45
|
+
embedder = EmbeddingClient(config)
|
|
46
|
+
if not await embedder.is_model_available():
|
|
47
|
+
if pull_model_fn is not None:
|
|
48
|
+
if not await pull_model_fn(config): # type: ignore[operator]
|
|
49
|
+
return
|
|
50
|
+
else:
|
|
51
|
+
console.print("[red]Embedding model not available.[/red]")
|
|
52
|
+
return
|
|
53
|
+
|
|
54
|
+
start_time = time.monotonic()
|
|
55
|
+
|
|
56
|
+
# Parse codebase
|
|
57
|
+
root = Path.cwd()
|
|
58
|
+
with Progress(
|
|
59
|
+
SpinnerColumn(),
|
|
60
|
+
TextColumn("[progress.description]{task.description}"),
|
|
61
|
+
console=console,
|
|
62
|
+
) as progress:
|
|
63
|
+
progress.add_task("Parsing codebase...", total=None)
|
|
64
|
+
chunks = parse_codebase(root, config.index)
|
|
65
|
+
|
|
66
|
+
if not chunks:
|
|
67
|
+
console.print("[yellow]No code chunks found to index.[/yellow]")
|
|
68
|
+
return
|
|
69
|
+
|
|
70
|
+
console.print(f"Found {len(chunks)} chunks from codebase.")
|
|
71
|
+
|
|
72
|
+
# Generate embeddings
|
|
73
|
+
texts = [c.content for c in chunks]
|
|
74
|
+
with Progress(
|
|
75
|
+
SpinnerColumn(),
|
|
76
|
+
TextColumn("[progress.description]{task.description}"),
|
|
77
|
+
BarColumn(),
|
|
78
|
+
TextColumn("{task.completed}/{task.total}"),
|
|
79
|
+
console=console,
|
|
80
|
+
) as progress:
|
|
81
|
+
task = progress.add_task("Generating embeddings...", total=len(texts))
|
|
82
|
+
embeddings = []
|
|
83
|
+
batch_size = 32
|
|
84
|
+
for i in range(0, len(texts), batch_size):
|
|
85
|
+
batch = texts[i : i + batch_size]
|
|
86
|
+
batch_embeddings = await embedder.embed_texts(batch)
|
|
87
|
+
embeddings.extend(batch_embeddings)
|
|
88
|
+
progress.update(task, completed=min(i + batch_size, len(texts)))
|
|
89
|
+
|
|
90
|
+
# Attach embeddings to chunks
|
|
91
|
+
for chunk, embedding in zip(chunks, embeddings):
|
|
92
|
+
chunk.embedding = embedding
|
|
93
|
+
|
|
94
|
+
# Store in LanceDB
|
|
95
|
+
with Progress(
|
|
96
|
+
SpinnerColumn(),
|
|
97
|
+
TextColumn("[progress.description]{task.description}"),
|
|
98
|
+
console=console,
|
|
99
|
+
) as progress:
|
|
100
|
+
progress.add_task("Writing index...", total=None)
|
|
101
|
+
await store.create_or_replace(chunks)
|
|
102
|
+
|
|
103
|
+
elapsed = time.monotonic() - start_time
|
|
104
|
+
file_count = len({c.file_path for c in chunks})
|
|
105
|
+
console.print(
|
|
106
|
+
f"[green]Indexed {len(chunks)} chunks from {file_count} files in {elapsed:.1f}s.[/green]"
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
async def show_index_status() -> None:
|
|
111
|
+
"""Show index statistics."""
|
|
112
|
+
config = load_config()
|
|
113
|
+
store = IndexStore(_get_index_dir())
|
|
114
|
+
stats = await store.status()
|
|
115
|
+
|
|
116
|
+
if not stats["exists"]:
|
|
117
|
+
console.print("[yellow]No index found. Run 'mita index build' first.[/yellow]")
|
|
118
|
+
return
|
|
119
|
+
|
|
120
|
+
table = Table(title="Index Status")
|
|
121
|
+
table.add_column("Metric", style="bold")
|
|
122
|
+
table.add_column("Value")
|
|
123
|
+
table.add_row("Chunks", str(stats["chunks"]))
|
|
124
|
+
table.add_row("Files", str(stats["files"]))
|
|
125
|
+
table.add_row("Location", str(_get_index_dir()))
|
|
126
|
+
table.add_row("Embedding model", config.model.embedding)
|
|
127
|
+
console.print(table)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
async def search_index(query: str, top_k: int = 10) -> None:
|
|
131
|
+
"""Search the index and display results."""
|
|
132
|
+
config = load_config()
|
|
133
|
+
retriever = Retriever(config, index_dir=_get_index_dir())
|
|
134
|
+
|
|
135
|
+
if not retriever.is_available():
|
|
136
|
+
console.print("[yellow]No index found. Run 'mita index build' first.[/yellow]")
|
|
137
|
+
return
|
|
138
|
+
|
|
139
|
+
results = await retriever.retrieve(query, top_k=top_k)
|
|
140
|
+
|
|
141
|
+
if not results:
|
|
142
|
+
console.print("[yellow]No results found.[/yellow]")
|
|
143
|
+
return
|
|
144
|
+
|
|
145
|
+
console.print(f"[bold]Found {len(results)} results:[/bold]\n")
|
|
146
|
+
for i, r in enumerate(results, 1):
|
|
147
|
+
c = r.chunk
|
|
148
|
+
header = f"{i}. {c.file_path}:{c.start_line}-{c.end_line}"
|
|
149
|
+
if c.symbol:
|
|
150
|
+
header += f" ({c.symbol})"
|
|
151
|
+
header += f" [dim]score: {r.score:.3f}[/dim]"
|
|
152
|
+
console.print(header)
|
|
153
|
+
console.print(Syntax(c.content, c.language, line_numbers=True, start_line=c.start_line))
|
|
154
|
+
console.print()
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
async def clear_index() -> None:
|
|
158
|
+
"""Clear the index."""
|
|
159
|
+
store = IndexStore(_get_index_dir())
|
|
160
|
+
|
|
161
|
+
if not store.exists():
|
|
162
|
+
console.print("[yellow]No index found.[/yellow]")
|
|
163
|
+
return
|
|
164
|
+
|
|
165
|
+
confirm = console.input("Delete the index? [y/N] ").strip().lower()
|
|
166
|
+
if confirm in ("y", "yes"):
|
|
167
|
+
await store.clear()
|
|
168
|
+
console.print("[green]Index cleared.[/green]")
|
|
169
|
+
else:
|
|
170
|
+
console.print("Cancelled.")
|
mita/index/parser.py
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
"""Tree-sitter code parsing and chunking for codebase indexing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from fnmatch import fnmatch
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from mita.config.schema import IndexSettings
|
|
10
|
+
from mita.index.store import CodeChunk
|
|
11
|
+
|
|
12
|
+
# Extension → tree-sitter language name
|
|
13
|
+
EXTENSION_MAP: dict[str, str] = {
|
|
14
|
+
".py": "python",
|
|
15
|
+
".js": "javascript",
|
|
16
|
+
".jsx": "javascript",
|
|
17
|
+
".ts": "typescript",
|
|
18
|
+
".tsx": "tsx",
|
|
19
|
+
".go": "go",
|
|
20
|
+
".rs": "rust",
|
|
21
|
+
".java": "java",
|
|
22
|
+
".c": "c",
|
|
23
|
+
".h": "c",
|
|
24
|
+
".cpp": "cpp",
|
|
25
|
+
".hpp": "cpp",
|
|
26
|
+
".cc": "cpp",
|
|
27
|
+
".rb": "ruby",
|
|
28
|
+
".php": "php",
|
|
29
|
+
".swift": "swift",
|
|
30
|
+
".kt": "kotlin",
|
|
31
|
+
".scala": "scala",
|
|
32
|
+
".lua": "lua",
|
|
33
|
+
".sh": "bash",
|
|
34
|
+
".bash": "bash",
|
|
35
|
+
".zsh": "bash",
|
|
36
|
+
".css": "css",
|
|
37
|
+
".html": "html",
|
|
38
|
+
".json": "json",
|
|
39
|
+
".yaml": "yaml",
|
|
40
|
+
".yml": "yaml",
|
|
41
|
+
".toml": "toml",
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
# Node types that represent semantic code units per language
|
|
45
|
+
CHUNK_NODE_TYPES: dict[str, set[str]] = {
|
|
46
|
+
"python": {"function_definition", "class_definition", "decorated_definition"},
|
|
47
|
+
"javascript": {
|
|
48
|
+
"function_declaration",
|
|
49
|
+
"class_declaration",
|
|
50
|
+
"method_definition",
|
|
51
|
+
"export_statement",
|
|
52
|
+
},
|
|
53
|
+
"typescript": {
|
|
54
|
+
"function_declaration",
|
|
55
|
+
"class_declaration",
|
|
56
|
+
"method_definition",
|
|
57
|
+
"export_statement",
|
|
58
|
+
"interface_declaration",
|
|
59
|
+
"type_alias_declaration",
|
|
60
|
+
},
|
|
61
|
+
"tsx": {
|
|
62
|
+
"function_declaration",
|
|
63
|
+
"class_declaration",
|
|
64
|
+
"method_definition",
|
|
65
|
+
"export_statement",
|
|
66
|
+
"interface_declaration",
|
|
67
|
+
"type_alias_declaration",
|
|
68
|
+
},
|
|
69
|
+
"go": {"function_declaration", "method_declaration", "type_declaration"},
|
|
70
|
+
"rust": {"function_item", "impl_item", "struct_item", "enum_item", "trait_item"},
|
|
71
|
+
"java": {"class_declaration", "method_declaration", "interface_declaration"},
|
|
72
|
+
"c": {"function_definition", "struct_specifier"},
|
|
73
|
+
"cpp": {"function_definition", "class_specifier", "struct_specifier"},
|
|
74
|
+
"ruby": {"method", "class", "module"},
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def parse_codebase(root: Path, config: IndexSettings) -> list[CodeChunk]:
|
|
79
|
+
"""Parse all supported files under root into code chunks."""
|
|
80
|
+
chunks: list[CodeChunk] = []
|
|
81
|
+
for file_path in _discover_files(root, config.exclude_patterns):
|
|
82
|
+
file_chunks = parse_file(file_path, root, config)
|
|
83
|
+
chunks.extend(file_chunks)
|
|
84
|
+
return chunks
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def parse_file(file_path: Path, root: Path, config: IndexSettings) -> list[CodeChunk]:
|
|
88
|
+
"""Parse a single file into code chunks."""
|
|
89
|
+
if _is_binary(file_path):
|
|
90
|
+
return []
|
|
91
|
+
|
|
92
|
+
try:
|
|
93
|
+
content = file_path.read_text(encoding="utf-8", errors="replace")
|
|
94
|
+
except OSError:
|
|
95
|
+
return []
|
|
96
|
+
|
|
97
|
+
if not content.strip():
|
|
98
|
+
return []
|
|
99
|
+
|
|
100
|
+
rel_path = str(file_path.relative_to(root))
|
|
101
|
+
language = get_language_for_file(file_path)
|
|
102
|
+
|
|
103
|
+
if language and language in CHUNK_NODE_TYPES:
|
|
104
|
+
chunks = _parse_with_treesitter(content, rel_path, language, config)
|
|
105
|
+
if chunks:
|
|
106
|
+
return chunks
|
|
107
|
+
|
|
108
|
+
# Fallback: line-based chunking
|
|
109
|
+
return _chunk_by_lines(content, rel_path, language or "text", config)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def get_language_for_file(path: Path) -> str | None:
|
|
113
|
+
"""Map a file extension to a tree-sitter language name."""
|
|
114
|
+
return EXTENSION_MAP.get(path.suffix.lower())
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _discover_files(root: Path, exclude_patterns: list[str]) -> list[Path]:
|
|
118
|
+
"""Walk the project and yield non-excluded files."""
|
|
119
|
+
files = []
|
|
120
|
+
for path in sorted(root.rglob("*")):
|
|
121
|
+
if not path.is_file():
|
|
122
|
+
continue
|
|
123
|
+
rel = str(path.relative_to(root))
|
|
124
|
+
if _matches_exclude(rel, exclude_patterns):
|
|
125
|
+
continue
|
|
126
|
+
files.append(path)
|
|
127
|
+
return files
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _matches_exclude(rel_path: str, patterns: list[str]) -> bool:
|
|
131
|
+
"""Check if a relative path matches any exclude pattern."""
|
|
132
|
+
for pattern in patterns:
|
|
133
|
+
if fnmatch(rel_path, pattern):
|
|
134
|
+
return True
|
|
135
|
+
# Also check each path component for directory patterns
|
|
136
|
+
parts = rel_path.split("/")
|
|
137
|
+
for i in range(len(parts)):
|
|
138
|
+
partial = "/".join(parts[: i + 1])
|
|
139
|
+
if fnmatch(partial, pattern.rstrip("/")):
|
|
140
|
+
return True
|
|
141
|
+
if fnmatch(partial + "/", pattern):
|
|
142
|
+
return True
|
|
143
|
+
return False
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _is_binary(path: Path) -> bool:
|
|
147
|
+
"""Heuristic: file is binary if first 8KB contains null bytes."""
|
|
148
|
+
try:
|
|
149
|
+
with open(path, "rb") as f:
|
|
150
|
+
chunk = f.read(8192)
|
|
151
|
+
return b"\x00" in chunk
|
|
152
|
+
except OSError:
|
|
153
|
+
return True
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _parse_with_treesitter(
|
|
157
|
+
content: str,
|
|
158
|
+
rel_path: str,
|
|
159
|
+
language: str,
|
|
160
|
+
config: IndexSettings,
|
|
161
|
+
) -> list[CodeChunk]:
|
|
162
|
+
"""Parse a file using tree-sitter and extract semantic chunks."""
|
|
163
|
+
try:
|
|
164
|
+
from tree_sitter_language_pack import get_parser
|
|
165
|
+
except ImportError:
|
|
166
|
+
return []
|
|
167
|
+
|
|
168
|
+
try:
|
|
169
|
+
parser = get_parser(language) # type: ignore[arg-type]
|
|
170
|
+
except Exception: # noqa: BLE001
|
|
171
|
+
return []
|
|
172
|
+
|
|
173
|
+
tree = parser.parse(content.encode("utf-8"))
|
|
174
|
+
node_types = CHUNK_NODE_TYPES.get(language, set())
|
|
175
|
+
lines = content.split("\n")
|
|
176
|
+
chunks: list[CodeChunk] = []
|
|
177
|
+
|
|
178
|
+
for node in _walk_top_level(tree.root_node):
|
|
179
|
+
if node.type not in node_types:
|
|
180
|
+
continue
|
|
181
|
+
|
|
182
|
+
start_line = node.start_point[0] + 1 # 1-indexed
|
|
183
|
+
end_line = node.end_point[0] + 1
|
|
184
|
+
node_content = "\n".join(lines[start_line - 1 : end_line])
|
|
185
|
+
symbol = _extract_symbol(node, language)
|
|
186
|
+
|
|
187
|
+
# Split large nodes
|
|
188
|
+
char_budget = config.chunk_size * 4 # ~4 chars per token
|
|
189
|
+
if len(node_content) > char_budget:
|
|
190
|
+
sub_chunks = _split_large_content(
|
|
191
|
+
node_content, start_line, rel_path, language, symbol, config
|
|
192
|
+
)
|
|
193
|
+
chunks.extend(sub_chunks)
|
|
194
|
+
else:
|
|
195
|
+
chunks.append(
|
|
196
|
+
CodeChunk(
|
|
197
|
+
file_path=rel_path,
|
|
198
|
+
start_line=start_line,
|
|
199
|
+
end_line=end_line,
|
|
200
|
+
content=node_content,
|
|
201
|
+
language=language,
|
|
202
|
+
symbol=symbol,
|
|
203
|
+
)
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
return chunks
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _walk_top_level(node: Any) -> list[Any]:
|
|
210
|
+
"""Get top-level children of the root node (non-recursive)."""
|
|
211
|
+
children = getattr(node, "children", [])
|
|
212
|
+
return list(children)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _extract_symbol(node: Any, language: str) -> str | None:
|
|
216
|
+
"""Extract the name of a function/class node."""
|
|
217
|
+
# Most languages store the name in a child node of type "identifier" or "name"
|
|
218
|
+
for child in getattr(node, "children", []):
|
|
219
|
+
child_type = getattr(child, "type", "")
|
|
220
|
+
if child_type in ("identifier", "name", "property_identifier"):
|
|
221
|
+
return getattr(child, "text", b"").decode("utf-8", errors="replace")
|
|
222
|
+
return None
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _split_large_content(
|
|
226
|
+
content: str,
|
|
227
|
+
base_start_line: int,
|
|
228
|
+
rel_path: str,
|
|
229
|
+
language: str,
|
|
230
|
+
symbol: str | None,
|
|
231
|
+
config: IndexSettings,
|
|
232
|
+
) -> list[CodeChunk]:
|
|
233
|
+
"""Split a large chunk into overlapping sub-chunks."""
|
|
234
|
+
lines = content.split("\n")
|
|
235
|
+
char_budget = config.chunk_size * 4
|
|
236
|
+
overlap_chars = config.chunk_overlap * 4
|
|
237
|
+
chunks: list[CodeChunk] = []
|
|
238
|
+
|
|
239
|
+
start = 0
|
|
240
|
+
while start < len(lines):
|
|
241
|
+
# Accumulate lines until we hit the budget
|
|
242
|
+
end = start
|
|
243
|
+
total_chars = 0
|
|
244
|
+
while end < len(lines) and total_chars < char_budget:
|
|
245
|
+
total_chars += len(lines[end]) + 1
|
|
246
|
+
end += 1
|
|
247
|
+
|
|
248
|
+
chunk_content = "\n".join(lines[start:end])
|
|
249
|
+
chunks.append(
|
|
250
|
+
CodeChunk(
|
|
251
|
+
file_path=rel_path,
|
|
252
|
+
start_line=base_start_line + start,
|
|
253
|
+
end_line=base_start_line + end - 1,
|
|
254
|
+
content=chunk_content,
|
|
255
|
+
language=language,
|
|
256
|
+
symbol=symbol,
|
|
257
|
+
)
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
# If we consumed all lines, we're done
|
|
261
|
+
if end >= len(lines):
|
|
262
|
+
break
|
|
263
|
+
|
|
264
|
+
# Advance with overlap
|
|
265
|
+
overlap_lines = 0
|
|
266
|
+
overlap_total = 0
|
|
267
|
+
for i in range(end - 1, start, -1):
|
|
268
|
+
overlap_total += len(lines[i]) + 1
|
|
269
|
+
overlap_lines += 1
|
|
270
|
+
if overlap_total >= overlap_chars:
|
|
271
|
+
break
|
|
272
|
+
|
|
273
|
+
new_start = end - overlap_lines
|
|
274
|
+
if new_start <= start:
|
|
275
|
+
break
|
|
276
|
+
start = new_start
|
|
277
|
+
|
|
278
|
+
return chunks
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _chunk_by_lines(
|
|
282
|
+
content: str,
|
|
283
|
+
rel_path: str,
|
|
284
|
+
language: str,
|
|
285
|
+
config: IndexSettings,
|
|
286
|
+
) -> list[CodeChunk]:
|
|
287
|
+
"""Fallback: chunk file by line windows."""
|
|
288
|
+
lines = content.split("\n")
|
|
289
|
+
char_budget = config.chunk_size * 4
|
|
290
|
+
overlap_chars = config.chunk_overlap * 4
|
|
291
|
+
chunks: list[CodeChunk] = []
|
|
292
|
+
|
|
293
|
+
start = 0
|
|
294
|
+
while start < len(lines):
|
|
295
|
+
end = start
|
|
296
|
+
total_chars = 0
|
|
297
|
+
while end < len(lines) and total_chars < char_budget:
|
|
298
|
+
total_chars += len(lines[end]) + 1
|
|
299
|
+
end += 1
|
|
300
|
+
|
|
301
|
+
chunk_content = "\n".join(lines[start:end])
|
|
302
|
+
if chunk_content.strip():
|
|
303
|
+
chunks.append(
|
|
304
|
+
CodeChunk(
|
|
305
|
+
file_path=rel_path,
|
|
306
|
+
start_line=start + 1,
|
|
307
|
+
end_line=end,
|
|
308
|
+
content=chunk_content,
|
|
309
|
+
language=language,
|
|
310
|
+
)
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
# If we consumed all lines, we're done
|
|
314
|
+
if end >= len(lines):
|
|
315
|
+
break
|
|
316
|
+
|
|
317
|
+
# Advance with overlap
|
|
318
|
+
overlap_lines = 0
|
|
319
|
+
overlap_total = 0
|
|
320
|
+
for i in range(end - 1, start, -1):
|
|
321
|
+
overlap_total += len(lines[i]) + 1
|
|
322
|
+
overlap_lines += 1
|
|
323
|
+
if overlap_total >= overlap_chars:
|
|
324
|
+
break
|
|
325
|
+
|
|
326
|
+
new_start = end - overlap_lines
|
|
327
|
+
if new_start <= start:
|
|
328
|
+
break
|
|
329
|
+
start = new_start
|
|
330
|
+
|
|
331
|
+
return chunks
|
mita/index/retriever.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""RAG retrieval pipeline: vector search over indexed code chunks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from mita.config.schema import MitaConfig
|
|
8
|
+
from mita.index.embeddings import EmbeddingClient
|
|
9
|
+
from mita.index.store import IndexStore, SearchResult
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _default_index_dir() -> Path:
|
|
13
|
+
"""Determine the index directory for the current project."""
|
|
14
|
+
return Path.cwd() / ".mita" / "index"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Retriever:
|
|
18
|
+
"""High-level retrieval combining embeddings + vector search."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, config: MitaConfig, index_dir: Path | None = None) -> None:
|
|
21
|
+
self._config = config
|
|
22
|
+
self._index_dir = index_dir or _default_index_dir()
|
|
23
|
+
self._store = IndexStore(self._index_dir)
|
|
24
|
+
self._embedder = EmbeddingClient(config)
|
|
25
|
+
|
|
26
|
+
async def retrieve(self, query: str, top_k: int | None = None) -> list[SearchResult]:
|
|
27
|
+
"""Retrieve relevant code chunks for a query."""
|
|
28
|
+
k = top_k or self._config.index.top_k
|
|
29
|
+
query_embedding = await self._embedder.embed_single(query)
|
|
30
|
+
return await self._store.search(query_embedding, top_k=k)
|
|
31
|
+
|
|
32
|
+
async def retrieve_formatted(self, query: str, top_k: int | None = None) -> str:
|
|
33
|
+
"""Retrieve and format results as a context string for the LLM."""
|
|
34
|
+
results = await self.retrieve(query, top_k=top_k)
|
|
35
|
+
if not results:
|
|
36
|
+
return ""
|
|
37
|
+
return format_results(results)
|
|
38
|
+
|
|
39
|
+
def is_available(self) -> bool:
|
|
40
|
+
"""Check if the index exists and is ready to query."""
|
|
41
|
+
return self._store.exists()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def format_results(results: list[SearchResult]) -> str:
|
|
45
|
+
"""Format search results as a readable context block."""
|
|
46
|
+
parts: list[str] = []
|
|
47
|
+
for r in results:
|
|
48
|
+
c = r.chunk
|
|
49
|
+
header = f"## {c.file_path}:{c.start_line}-{c.end_line}"
|
|
50
|
+
if c.symbol:
|
|
51
|
+
header += f" ({c.symbol})"
|
|
52
|
+
parts.append(f"{header}\n```{c.language}\n{c.content}\n```")
|
|
53
|
+
return "\n\n".join(parts)
|
mita/index/store.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""LanceDB vector store operations for code chunk storage and retrieval."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import shutil
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import lancedb # type: ignore[import-untyped]
|
|
11
|
+
from pydantic import BaseModel, Field
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class CodeChunk(BaseModel):
|
|
15
|
+
"""A chunk of code extracted from a source file."""
|
|
16
|
+
|
|
17
|
+
file_path: str
|
|
18
|
+
start_line: int
|
|
19
|
+
end_line: int
|
|
20
|
+
content: str
|
|
21
|
+
language: str
|
|
22
|
+
symbol: str | None = None
|
|
23
|
+
embedding: list[float] | None = Field(default=None, exclude=True)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class SearchResult(BaseModel):
|
|
27
|
+
"""A code chunk with its similarity score."""
|
|
28
|
+
|
|
29
|
+
chunk: CodeChunk
|
|
30
|
+
score: float
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class IndexStore:
|
|
34
|
+
"""LanceDB-backed vector store for code chunks."""
|
|
35
|
+
|
|
36
|
+
TABLE_NAME = "code_chunks"
|
|
37
|
+
|
|
38
|
+
def __init__(self, index_dir: Path) -> None:
|
|
39
|
+
self._index_dir = index_dir
|
|
40
|
+
self._db_path = index_dir / "lancedb"
|
|
41
|
+
|
|
42
|
+
def _connect(self) -> lancedb.DBConnection:
|
|
43
|
+
self._db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
44
|
+
return lancedb.connect(str(self._db_path))
|
|
45
|
+
|
|
46
|
+
async def create_or_replace(self, chunks: list[CodeChunk]) -> None:
|
|
47
|
+
"""Create or replace the index with the given chunks."""
|
|
48
|
+
if not chunks:
|
|
49
|
+
return
|
|
50
|
+
|
|
51
|
+
def _sync() -> None:
|
|
52
|
+
db = self._connect()
|
|
53
|
+
records = _chunks_to_records(chunks)
|
|
54
|
+
if self.TABLE_NAME in db.table_names():
|
|
55
|
+
db.drop_table(self.TABLE_NAME)
|
|
56
|
+
db.create_table(self.TABLE_NAME, data=records)
|
|
57
|
+
|
|
58
|
+
await asyncio.to_thread(_sync)
|
|
59
|
+
|
|
60
|
+
async def search(self, query_embedding: list[float], top_k: int = 10) -> list[SearchResult]:
|
|
61
|
+
"""Vector similarity search."""
|
|
62
|
+
|
|
63
|
+
def _sync() -> list[SearchResult]:
|
|
64
|
+
db = self._connect()
|
|
65
|
+
if self.TABLE_NAME not in db.table_names():
|
|
66
|
+
return []
|
|
67
|
+
table = db.open_table(self.TABLE_NAME)
|
|
68
|
+
results = table.search(query_embedding).limit(top_k).to_pandas()
|
|
69
|
+
|
|
70
|
+
search_results: list[SearchResult] = []
|
|
71
|
+
for _, row in results.iterrows():
|
|
72
|
+
distance = row.get("_distance", 0.0)
|
|
73
|
+
score = 1.0 / (1.0 + distance)
|
|
74
|
+
chunk = CodeChunk(
|
|
75
|
+
file_path=row["file_path"],
|
|
76
|
+
start_line=int(row["start_line"]),
|
|
77
|
+
end_line=int(row["end_line"]),
|
|
78
|
+
content=row["content"],
|
|
79
|
+
language=row["language"],
|
|
80
|
+
symbol=row.get("symbol"),
|
|
81
|
+
)
|
|
82
|
+
search_results.append(SearchResult(chunk=chunk, score=score))
|
|
83
|
+
return search_results
|
|
84
|
+
|
|
85
|
+
return await asyncio.to_thread(_sync)
|
|
86
|
+
|
|
87
|
+
async def clear(self) -> None:
|
|
88
|
+
"""Delete the entire index."""
|
|
89
|
+
|
|
90
|
+
def _sync() -> None:
|
|
91
|
+
if self._db_path.exists():
|
|
92
|
+
shutil.rmtree(self._db_path)
|
|
93
|
+
|
|
94
|
+
await asyncio.to_thread(_sync)
|
|
95
|
+
|
|
96
|
+
async def status(self) -> dict[str, Any]:
|
|
97
|
+
"""Return index statistics."""
|
|
98
|
+
|
|
99
|
+
def _sync() -> dict[str, Any]:
|
|
100
|
+
if not self._db_path.exists():
|
|
101
|
+
return {"exists": False, "chunks": 0, "files": 0}
|
|
102
|
+
db = self._connect()
|
|
103
|
+
if self.TABLE_NAME not in db.table_names():
|
|
104
|
+
return {"exists": False, "chunks": 0, "files": 0}
|
|
105
|
+
table = db.open_table(self.TABLE_NAME)
|
|
106
|
+
df = table.to_pandas()
|
|
107
|
+
return {
|
|
108
|
+
"exists": True,
|
|
109
|
+
"chunks": len(df),
|
|
110
|
+
"files": df["file_path"].nunique() if len(df) > 0 else 0,
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return await asyncio.to_thread(_sync)
|
|
114
|
+
|
|
115
|
+
def exists(self) -> bool:
|
|
116
|
+
"""Check if the index exists on disk."""
|
|
117
|
+
if not self._db_path.exists():
|
|
118
|
+
return False
|
|
119
|
+
try:
|
|
120
|
+
db = self._connect()
|
|
121
|
+
return self.TABLE_NAME in db.table_names()
|
|
122
|
+
except Exception: # noqa: BLE001
|
|
123
|
+
return False
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _chunks_to_records(chunks: list[CodeChunk]) -> list[dict[str, Any]]:
|
|
127
|
+
"""Convert CodeChunks (with embeddings) to LanceDB-compatible records."""
|
|
128
|
+
records = []
|
|
129
|
+
for chunk in chunks:
|
|
130
|
+
if chunk.embedding is None:
|
|
131
|
+
continue
|
|
132
|
+
records.append(
|
|
133
|
+
{
|
|
134
|
+
"file_path": chunk.file_path,
|
|
135
|
+
"start_line": chunk.start_line,
|
|
136
|
+
"end_line": chunk.end_line,
|
|
137
|
+
"content": chunk.content,
|
|
138
|
+
"language": chunk.language,
|
|
139
|
+
"symbol": chunk.symbol or "",
|
|
140
|
+
"vector": chunk.embedding,
|
|
141
|
+
}
|
|
142
|
+
)
|
|
143
|
+
return records
|
mita/llm/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""LLM client abstraction: LiteLLM + Instructor + streaming."""
|