agent-coderag 1.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.
@@ -0,0 +1,188 @@
1
+ Metadata-Version: 2.4
2
+ Name: agent-coderag
3
+ Version: 1.1.0
4
+ Summary: Lightweight semantic code search and distillation utility for AI coding agents. It solves the API knowledge gap via real-time local signature extraction and intent analysis without PyTorch. Optimized for token efficiency, it compresses codebase context into compact semantic summaries stored in a local DuckDB vector similarity index.
5
+ Author-email: Igor Boloban <naranor@gmail.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/naranor/agent-coderag
8
+ Project-URL: Repository, https://github.com/naranor/agent-coderag
9
+ Project-URL: Issues, https://github.com/naranor/agent-coderag/issues
10
+ Project-URL: Changelog, https://github.com/naranor/agent-coderag/blob/main/CHANGELOG.md
11
+ Keywords: rag,ai-agents,semantic-search,code-analysis,context-compression,onnx,local-embeddings
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
20
+ Classifier: Topic :: Text Processing :: Indexing
21
+ Requires-Python: >=3.10
22
+ Description-Content-Type: text/markdown
23
+ License-File: LICENSE
24
+ License-File: NOTICE
25
+ Requires-Dist: duckdb
26
+ Requires-Dist: numpy
27
+ Requires-Dist: litellm
28
+ Requires-Dist: onnxruntime
29
+ Requires-Dist: tokenizers
30
+ Requires-Dist: pydantic
31
+ Requires-Dist: httpx
32
+ Requires-Dist: aiofiles
33
+ Dynamic: license-file
34
+
35
+ # Agent-CodeRAG: Semantic Intelligence for AI Coding Agents
36
+
37
+ > **Fast. Local. Agent-First. Token-Efficient.**
38
+
39
+ [![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/)
40
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
41
+ [![No PyTorch](https://img.shields.io/badge/Footprint-No_PyTorch-green.svg)](#-key-technologies)
42
+ [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](CONTRIBUTING.md)
43
+
44
+ ---
45
+
46
+ ## 📖 Table of Contents
47
+ - [🧠 The Problem: The API Knowledge Gap](#-the-problem-the-api-knowledge-gap)
48
+ - [🚀 The Solution: Real-Time Contextual Truth](#-the-solution-real-time-contextual-truth)
49
+ - [🛠 How it Works](#-how-it-works)
50
+ - [📡 API Discovery](#-api-discovery)
51
+ - [🏃 Quick Start](#-quick-start)
52
+ - [🤖 For AI Agents](#-for-ai-agents)
53
+ - [🔧 Development](#-development)
54
+ - [📄 License](#-license)
55
+
56
+ ---
57
+
58
+ ## 🧠 The Problem: The API Knowledge Gap
59
+
60
+ AI coding agents often hallucinate when calling library APIs because their training data is static. This leads to a **"Fail-Fix-Fail" cycle**:
61
+
62
+ 1. **Broken Code**: Agents use deprecated parameters or non-existent methods from outdated versions.
63
+ 2. **Token Waste**: You provide the error, the agent tries to fix it using more outdated data, consuming thousands of tokens in a loop.
64
+ 3. **Environment Mismatch**: The agent knows the API for version 1.0, but your environment has 2.0.
65
+
66
+ ### Real-world Example (The Pydantic Gap)
67
+ * **Agent's Knowledge**: Knows Pydantic v1 (`model.dict()`).
68
+ * **Your Environment**: Uses Pydantic v2 (`model.model_dump()`).
69
+ * **The Result**: The agent writes `dict()`, the code fails, and it wastes **5000+ tokens** trying to "fix" a problem it doesn't understand.
70
+
71
+ ## 🚀 The Solution: Real-Time Contextual Truth
72
+
73
+ Agent-CodeRAG acts as a lightweight semantic bridge between your local environment and the LLM.
74
+
75
+ * **API Discovery**: Extracts *actual* signatures from your installed libraries.
76
+ * **Semantic Retrieval**: Provides the LLM with the exact **Intent** of your code units, indexed locally via ONNX.
77
+ * **Token Efficiency**: Instead of sending whole files, Agent-CodeRAG distills code into compact semantic summaries, **saving up to 80% of context window tokens**.
78
+
79
+ ---
80
+
81
+ ## 🛠 How it Works
82
+
83
+ ```mermaid
84
+ graph TD
85
+ A[Local Python Code] --> B[AST Parser]
86
+ B --> C{Delta-Sync}
87
+ C -- Changed/New --> D[LLM Distiller]
88
+ C -- Unchanged --> E[Local Cache]
89
+ D --> F[Semantic Summary]
90
+ E --> F
91
+ F --> G[ONNX Embedder]
92
+ G --> H[(DuckDB VSS)]
93
+ H --> I[Semantic Search / JSON API]
94
+ ```
95
+
96
+ ### ✨ Key Features
97
+ * **⚡ No PyTorch**: Uses `onnxruntime` and `tokenizers` (Rust) for a tiny footprint and instant startup.
98
+ * **💾 DuckDB VSS**: High-performance vector similarity search stored in a single local file.
99
+ * **🔄 Delta-Sync**: Uses SHA-256 hashing to only re-distill changed code, saving your API budget.
100
+ * **🔌 Hybrid Intelligence**: Works offline using name-based embeddings; adds AI-distilled reasoning when an LLM is connected.
101
+
102
+ ---
103
+
104
+ ## 📡 API Discovery
105
+ To help your agent understand a specific library version installed in your environment:
106
+ ```bash
107
+ agent-coderag api pydantic
108
+ ```
109
+ Returns the *live* public API, methods, and signatures.
110
+
111
+ ---
112
+
113
+ ## 🏃 Quick Start
114
+
115
+ ### 1. Install
116
+ ```bash
117
+ pip install agent-coderag
118
+ ```
119
+
120
+ ### 2. Setup AI Models
121
+ Download the lightweight `paraphrase-multilingual-MiniLM` ONNX model to your global cache:
122
+ ```bash
123
+ agent-coderag setup
124
+ ```
125
+
126
+ ### 3. Configure your LLM (For Distillation)
127
+
128
+ **Option A: Cloud (OpenAI)**
129
+ ```bash
130
+ agent-coderag config --url "https://api.openai.com/v1" --model "gpt-4o-mini" --key "sk-..."
131
+ ```
132
+
133
+ **Option B: Local (Ollama) - Recommended**
134
+ ```bash
135
+ agent-coderag config --url "http://localhost:11434" --provider "ollama" --model "qwen2.5-coder:7b"
136
+ ```
137
+ *We recommend using `qwen2.5-coder` or `llama3.2` for fast and private local distillation.*
138
+
139
+
140
+ ### 4. Index your Project
141
+ ```bash
142
+ agent-coderag sync --all
143
+ ```
144
+
145
+ ### 5. Search
146
+ * **Human Mode (Compact)**: `agent-coderag search "how to handle errors"`
147
+ * **Agent Mode (JSON)**: `agent-coderag --json search "data storage" --limit 1`
148
+
149
+ ### 🐳 Docker (Alternative)
150
+ ```bash
151
+ docker build -t agent-coderag .
152
+ docker run -v ~/.cache/agent-coderag:/root/.cache/agent-coderag agent-coderag setup
153
+ ```
154
+
155
+ ---
156
+
157
+ ## 🤖 For AI Agents
158
+
159
+ Agent-CodeRAG is built specifically for programmatic consumption.
160
+
161
+ ### Agent Strategy
162
+ 1. **Search First**: Use `agent-coderag --json search "topic"` to find relevant code units before reading files.
163
+ 2. **Use Intent**: The `summary` field provides technical intent, allowing you to skip reading complex implementation details.
164
+
165
+ ---
166
+
167
+ ## 🔧 Development
168
+
169
+ ### Running Tests
170
+ ```bash
171
+ pytest tests/
172
+ pytest e2e_tests/
173
+ ```
174
+
175
+ ### Pre-commit Hooks
176
+ We use `pre-commit` to maintain high code standards:
177
+ ```bash
178
+ pip install pre-commit
179
+ pre-commit install
180
+ ```
181
+
182
+ ---
183
+
184
+ ## 📄 License
185
+ MIT © 2026 Igor Boloban
186
+
187
+ ## 🙏 Acknowledgments
188
+ This project stands on the shoulders of giants. See [THIRD_PARTY_LICENSES.md](THIRD_PARTY_LICENSES.md) for a full list of open-source libraries used in Agent-CodeRAG.
@@ -0,0 +1,23 @@
1
+ agent_coderag-1.1.0.dist-info/licenses/LICENSE,sha256=OV_lazXI3hc0Pqgm9hZhnJpHRUXWK4GS3sxaWmCDSHY,1069
2
+ agent_coderag-1.1.0.dist-info/licenses/NOTICE,sha256=Ssc-bWjkZBz0sx1egO429_fmuKz9_vZxmO2frUZe5uk,802
3
+ code_rag/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ code_rag/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ code_rag/core/interfaces.py,sha256=V9f2JLBxx2R9zwC8jk2gQGRS1ZC7UNh8C0pYxBKfFG4,867
6
+ code_rag/core/manager.py,sha256=W_G8AeKf63pGEhqByB0DQUfiZJCf-wcn2btCzqH-wSo,2832
7
+ code_rag/core/models.py,sha256=m9qd_eGqIhTgu4cHQGNfzIcvQk4XUz0mngHKgYc4QUM,881
8
+ code_rag/discovery/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
+ code_rag/discovery/dependency.py,sha256=yatONVT_ubx0c_uaGQ9G5Yy1jgTivjtEsKcrLVuUsR4,1450
10
+ code_rag/entry/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
+ code_rag/entry/cli.py,sha256=Tj3VCdc0VGJa9ypAiWdtkdo9eCwjK7-b-sHYOUIFv7A,7576
12
+ code_rag/intelligence/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
13
+ code_rag/intelligence/distiller.py,sha256=P9NkgqSJJtAZm4Ngr98gW_WoP-Xk9gtvCfGp_q5jV9I,2732
14
+ code_rag/intelligence/embedder.py,sha256=hbgv8OiLkvFIsMgTxwBekDGC7Xu853Mt_zWHeex1nrA,4264
15
+ code_rag/parsers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
16
+ code_rag/parsers/ast_index.py,sha256=1SCAR7glGesFtEg26-GGvzqqqsUD-i7nWiXodVYK2OE,2776
17
+ code_rag/storage/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
18
+ code_rag/storage/duckdb_impl.py,sha256=kU87-4qaERGv9pcZzwUWilxhbBYvp6GQygl49GVeaJ4,3986
19
+ agent_coderag-1.1.0.dist-info/METADATA,sha256=Q7a5TmjFZlctVBEwBT1rWTa7z8_22DiywGKAhkOWH2E,6798
20
+ agent_coderag-1.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
21
+ agent_coderag-1.1.0.dist-info/entry_points.txt,sha256=qt05eUIhUxid27O-C1RK1-Dz9uvKqSBeGpbzZCkm1fk,58
22
+ agent_coderag-1.1.0.dist-info/top_level.txt,sha256=8W3_FE9oVHnpu46qinUXMuDnvAGnGWd1AHqsqvpIzOw,9
23
+ agent_coderag-1.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ agent-coderag = code_rag.entry.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Igor Boloban
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,17 @@
1
+ CodeRAG
2
+ Copyright 2026 Igor Boloban
3
+
4
+ This product includes software developed by the following third-party projects:
5
+
6
+ - DuckDB (MIT License) - https://duckdb.org/
7
+ - NumPy (BSD License) - https://numpy.org/
8
+ - LiteLLM (MIT License) - https://litellm.ai/
9
+ - ONNX Runtime (MIT License) - https://onnxruntime.ai/
10
+ - Tokenizers (Apache License 2.0) - https://github.com/huggingface/tokenizers
11
+ - Pydantic (MIT License) - https://github.com/pydantic/pydantic
12
+ - HTTPX (BSD License) - https://github.com/encode/httpx
13
+ - Aiofiles (Apache License 2.0) - https://github.com/Tinche/aiofiles
14
+ - Sentence Transformers (Apache License 2.0) - https://www.sbert.net/
15
+
16
+ This product also uses various other open-source libraries as listed in THIRD_PARTY_LICENSES.md.
17
+ All trademarks are the property of their respective owners.
@@ -0,0 +1 @@
1
+ code_rag
code_rag/__init__.py ADDED
File without changes
File without changes
@@ -0,0 +1,29 @@
1
+ from abc import ABC, abstractmethod
2
+ from typing import List, Optional
3
+ from .models import KnowledgeUnit
4
+
5
+ class IParser(ABC):
6
+ """Interface for extracting structure from code."""
7
+ @abstractmethod
8
+ async def distill_file(self, file_path: str) -> List[KnowledgeUnit]:
9
+ pass
10
+
11
+ class IStorage(ABC):
12
+ """Interface for storing the index."""
13
+ @abstractmethod
14
+ async def upsert_unit(self, unit: KnowledgeUnit):
15
+ pass
16
+
17
+ @abstractmethod
18
+ async def get_unit(self, unit_id: str) -> Optional[KnowledgeUnit]:
19
+ pass
20
+
21
+ @abstractmethod
22
+ async def search_units(self, query: str, limit: int = 5) -> List[KnowledgeUnit]:
23
+ pass
24
+
25
+ class IIntelligence(ABC):
26
+ """Interface for LLM-based analysis (distillation, embeddings)."""
27
+ @abstractmethod
28
+ async def summarize(self, code: str, unit_name: str) -> str:
29
+ pass
@@ -0,0 +1,70 @@
1
+ import logging
2
+ import asyncio
3
+ from typing import List
4
+ from .interfaces import IStorage, IParser, IIntelligence
5
+ from .models import KnowledgeUnit
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+ class CodeRAGManager:
10
+ """
11
+ Orchestrates the RAG workflow: parsing, distillation, and storage.
12
+ """
13
+
14
+ def __init__(self, storage: IStorage, parser: IParser, intelligence: IIntelligence):
15
+ self.storage = storage
16
+ self.parser = parser
17
+ self.intelligence = intelligence
18
+
19
+ async def sync_file(self, file_path: str, force_distill: bool = False):
20
+ """
21
+ Processes a single file and syncs it with the storage.
22
+ """
23
+ # 1. Parse AST to get units
24
+ current_units = await self.parser.distill_file(file_path)
25
+
26
+ for unit in current_units:
27
+ # v5.40: Delta-distillation logic
28
+ raw_code = unit.metadata.pop("raw_code", "")
29
+
30
+ # 2. Get existing unit to check hash
31
+ existing_unit = await self.storage.get_unit(unit.id)
32
+
33
+ should_distill = force_distill
34
+ if not existing_unit:
35
+ should_distill = True
36
+ logger.info("New unit discovered: %s", unit.name)
37
+ elif existing_unit.code_hash != unit.code_hash:
38
+ should_distill = True
39
+ logger.info("Unit %s changed (hash mismatch)", unit.name)
40
+ elif not existing_unit.summary:
41
+ should_distill = True
42
+ logger.info("Summary missing for %s", unit.name)
43
+
44
+ if should_distill:
45
+ logger.info("Distilling summary for %s...", unit.id)
46
+ try:
47
+ unit.summary = await self.intelligence.summarize(raw_code, unit.name)
48
+ except Exception as e:
49
+ logger.error("Failed to distill %s: %s", unit.name, e)
50
+ # Keep old summary if available, otherwise stay None
51
+ unit.summary = existing_unit.summary if existing_unit else None
52
+ else:
53
+ # Reuse existing summary if code hasn't changed
54
+ unit.summary = existing_unit.summary if existing_unit else None
55
+
56
+ # 3. Save to storage (includes embedding generation)
57
+ await self.storage.upsert_unit(unit)
58
+
59
+ async def search(self, query: str, limit: int = 5) -> List[KnowledgeUnit]:
60
+ """
61
+ Performs semantic search across all indexed units.
62
+ """
63
+ return await self.storage.search_units(query, limit=limit)
64
+
65
+ async def sync_project(self, paths: List[str], force_distill: bool = False):
66
+ """
67
+ Concurrent synchronization of multiple files.
68
+ """
69
+ tasks = [self.sync_file(p, force_distill=force_distill) for p in paths]
70
+ await asyncio.gather(*tasks)
@@ -0,0 +1,33 @@
1
+ from enum import Enum
2
+ from typing import List, Dict, Any, Optional
3
+ from pydantic import BaseModel, Field
4
+
5
+ class UnitKind(str, Enum):
6
+ MODULE = "module"
7
+ CLASS = "class"
8
+ FUNCTION = "function"
9
+ METHOD = "method"
10
+
11
+ class RelationType(str, Enum):
12
+ IMPORTS = "imports" # used
13
+ CALLS = "calls" # used
14
+ INHERITS = "inherits" # used
15
+ DEFINES = "defines" # used
16
+
17
+ class KnowledgeUnit(BaseModel):
18
+ """Represents a piece of code (function, class, module)."""
19
+ id: str
20
+ kind: UnitKind
21
+ name: str
22
+ path: str
23
+ signature: Optional[str] = None
24
+ summary: Optional[str] = None
25
+ code_hash: str
26
+ tags: List[str] = Field(default_factory=list)
27
+ metadata: Dict[str, Any] = Field(default_factory=dict)
28
+
29
+ class Relation(BaseModel):
30
+ """Connection between knowledge units."""
31
+ from_id: str # used
32
+ to_id: str # used
33
+ type: RelationType # used
File without changes
@@ -0,0 +1,40 @@
1
+ import importlib
2
+ import inspect
3
+ import logging
4
+
5
+ logger = logging.getLogger(__name__)
6
+
7
+ def _get_method_signature(obj) -> str:
8
+ """Helper to safely get a signature string."""
9
+ try:
10
+ return str(inspect.signature(obj))
11
+ except (ValueError, TypeError):
12
+ return "(...)"
13
+
14
+ async def extract_library_api(library_name: str) -> str:
15
+ """
16
+ Extracts the public API (classes, methods) of an installed library using introspection.
17
+ """
18
+ try:
19
+ lib = importlib.import_module(library_name)
20
+ output = [f"# Public API for '{library_name}':"]
21
+
22
+ for name, obj in inspect.getmembers(lib):
23
+ if name.startswith("_"):
24
+ continue
25
+
26
+ if inspect.isclass(obj):
27
+ output.append(f"- **Class: {name}**")
28
+ for m_name, m_obj in inspect.getmembers(obj):
29
+ if m_name.startswith("_"):
30
+ continue
31
+ if inspect.isfunction(m_obj) or inspect.ismethod(m_obj):
32
+ sig = _get_method_signature(m_obj)
33
+ output.append(f" - `{m_name}{sig}`")
34
+ elif inspect.isfunction(obj) or inspect.isbuiltin(obj):
35
+ sig = _get_method_signature(obj)
36
+ output.append(f"- **Function: {name}{sig}**")
37
+
38
+ return "\n".join(output[:100]) # Limit output length
39
+ except Exception as e:
40
+ return f"Failed to extract API for '{library_name}': {e}"
File without changes
code_rag/entry/cli.py ADDED
@@ -0,0 +1,214 @@
1
+ import asyncio
2
+ import argparse
3
+ import os
4
+ import sys
5
+ import logging
6
+ import json
7
+ from pathlib import Path
8
+ from typing import Optional
9
+ import httpx
10
+
11
+ # Suppress external library noise before they are imported by other modules
12
+ os.environ["LITELLM_VERBOSE"] = "FALSE"
13
+ logging.getLogger("LiteLLM").setLevel(logging.WARNING)
14
+ logging.getLogger("onnxruntime").setLevel(logging.ERROR)
15
+
16
+ # pylint: disable=wrong-import-position
17
+ from ..core.manager import CodeRAGManager
18
+ from ..storage.duckdb_impl import DuckDBStorage
19
+ from ..parsers.ast_index import AstIndexParser
20
+ from ..intelligence.embedder import Embedder, get_default_model_dir
21
+ from ..intelligence.distiller import Distiller, DistillerConfig
22
+ from ..discovery.dependency import extract_library_api
23
+ # pylint: enable=wrong-import-position
24
+
25
+ # Setup basic logging - default to WARNING for clean output
26
+ logging.basicConfig(level=logging.WARNING, format="%(levelname)s: %(message)s")
27
+ logger = logging.getLogger("agent-coderag")
28
+
29
+ def get_manager(db_path: str, onnx_path: Optional[str] = None, verbose: bool = False):
30
+ """Initializes the RAG manager."""
31
+ if verbose:
32
+ logging.getLogger().setLevel(logging.INFO)
33
+
34
+ config = DistillerConfig.load()
35
+ config.api_base = os.getenv("AGENT_PROXY_URL", config.api_base)
36
+ config.api_key = os.getenv("AGENT_PROXY_KEY", config.api_key)
37
+ config.model = os.getenv("AGENT_MODEL", config.model)
38
+ config.provider = os.getenv("AGENT_PROVIDER", config.provider)
39
+
40
+ embedder = Embedder(model_path=onnx_path)
41
+ storage = DuckDBStorage(db_path, embedder=embedder)
42
+ parser = AstIndexParser()
43
+ distiller = Distiller(config)
44
+
45
+ return CodeRAGManager(storage, parser, distiller)
46
+
47
+ def should_index(path: Path) -> bool:
48
+ """Filters files that should NOT be indexed."""
49
+ p_str = str(path)
50
+ exclude_patterns = ["tests/", "venv/", "__pycache__/", ".git/"]
51
+ for pattern in exclude_patterns:
52
+ if pattern in p_str:
53
+ return False
54
+ return path.suffix == ".py"
55
+
56
+ async def sync_cmd(args):
57
+ manager = get_manager(args.db, args.onnx, args.verbose)
58
+ if args.path:
59
+ target_path = Path(args.path)
60
+ if target_path.is_file():
61
+ await manager.sync_file(str(target_path), force_distill=args.force)
62
+ else:
63
+ paths = [str(p) for p in target_path.rglob("*.py") if should_index(p)]
64
+ if args.verbose:
65
+ logger.info("Indexing %d files...", len(paths))
66
+ await manager.sync_project(paths, force_distill=args.force)
67
+ elif args.all:
68
+ paths = [str(p) for p in Path(".").rglob("*.py") if should_index(p)]
69
+ if args.verbose:
70
+ logger.info("Indexing %d files...", len(paths))
71
+ await manager.sync_project(paths, force_distill=args.force)
72
+
73
+ if not args.json:
74
+ print("Done.")
75
+ else:
76
+ print(json.dumps({"status": "success"}))
77
+
78
+ async def search_cmd(args):
79
+ manager = get_manager(args.db, args.onnx, args.verbose)
80
+ results = await manager.search(args.query, limit=args.limit)
81
+
82
+ if args.json:
83
+ output = [unit.model_dump() for unit in results]
84
+ print(json.dumps(output, indent=2, ensure_ascii=False))
85
+ return
86
+
87
+ if not results:
88
+ print("No results.")
89
+ return
90
+
91
+ for unit in results:
92
+ print(f"[{unit.kind.value}] {unit.name} | {unit.path}")
93
+ if unit.summary:
94
+ print(f" {unit.summary}")
95
+ print("-" * 20)
96
+
97
+ async def api_cmd(args):
98
+ output = await extract_library_api(args.library)
99
+ if args.json:
100
+ print(json.dumps({"api": output}))
101
+ else:
102
+ print(output)
103
+
104
+ async def download_file(url: str, dest: Path):
105
+ dest.parent.mkdir(parents=True, exist_ok=True)
106
+ async with httpx.AsyncClient(follow_redirects=True) as client:
107
+ async with client.stream("GET", url) as response:
108
+ if response.status_code != 200:
109
+ return False
110
+ with open(dest, "wb") as f:
111
+ async for chunk in response.aiter_bytes():
112
+ f.write(chunk)
113
+ return True
114
+
115
+ async def setup_cmd(args):
116
+ dest_dir = get_default_model_dir()
117
+ base_url = "https://huggingface.co/sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2/resolve/main"
118
+ files = {
119
+ "model.onnx": f"{base_url}/onnx/model.onnx",
120
+ "tokenizer.json": f"{base_url}/tokenizer.json"
121
+ }
122
+ force = getattr(args, "force", False)
123
+ for filename, url in files.items():
124
+ dest_path = dest_dir / filename
125
+ if not dest_path.exists() or force:
126
+ await download_file(url, dest_path)
127
+ print("Setup complete.")
128
+
129
+ def config_cmd(args):
130
+ config = DistillerConfig.load()
131
+ if args.url:
132
+ config.api_base = args.url
133
+ if args.key:
134
+ config.api_key = args.key
135
+ if args.model:
136
+ config.model = args.model
137
+ if args.provider:
138
+ config.provider = args.provider
139
+ config.save()
140
+ if args.json:
141
+ print(json.dumps(config.model_dump()))
142
+ else:
143
+ print("Config updated.")
144
+
145
+ async def rebuild_cmd(args):
146
+ db_path = Path(args.db)
147
+ if db_path.exists():
148
+ if args.verbose:
149
+ logger.info("Removing old database: %s", db_path)
150
+ db_path.unlink()
151
+
152
+ # Trigger full sync
153
+ args.all = True
154
+ args.force = True
155
+ args.path = None
156
+ await sync_cmd(args)
157
+
158
+
159
+ def main():
160
+ parser = argparse.ArgumentParser(description="Agent-CodeRAG CLI Tool")
161
+ parser.add_argument("--db", default=".code_rag.db", help="Path to DuckDB database")
162
+ parser.add_argument("--onnx", help="Path to ONNX embedding model")
163
+ parser.add_argument("--verbose", action="store_true", help="Show debug logs")
164
+ parser.add_argument("--json", action="store_true", help="Output results in JSON format")
165
+
166
+ subparsers = parser.add_subparsers(dest="command", required=True)
167
+
168
+ # Commands
169
+ subparsers.add_parser("setup", help="Download models")
170
+ subparsers.add_parser("rebuild", help="Nuke database and re-index everything")
171
+
172
+ conf_p = subparsers.add_parser("config", help="AI settings")
173
+ conf_p.add_argument("--url", help="API base URL")
174
+ conf_p.add_argument("--key", help="API key")
175
+ conf_p.add_argument("--model", help="Model name")
176
+ conf_p.add_argument("--provider", help="Provider")
177
+
178
+ sync_p = subparsers.add_parser("sync", help="Index files")
179
+ sync_p.add_argument("path", nargs="?", help="Path to index")
180
+ sync_p.add_argument("--all", action="store_true", help="Index all python files")
181
+ sync_p.add_argument("--force", action="store_true", help="Force distillation")
182
+
183
+ search_p = subparsers.add_parser("search", help="Semantic search")
184
+ search_p.add_argument("query", help="Query")
185
+ search_p.add_argument("--limit", type=int, default=5, help="Max results")
186
+
187
+ api_p = subparsers.add_parser("api", help="Discover library API")
188
+ api_p.add_argument("library", help="Library name")
189
+
190
+ args = parser.parse_args()
191
+
192
+ try:
193
+ if args.command == "setup":
194
+ asyncio.run(setup_cmd(args))
195
+ elif args.command == "config":
196
+ config_cmd(args)
197
+ elif args.command == "rebuild":
198
+ asyncio.run(rebuild_cmd(args))
199
+ elif args.command == "sync":
200
+ asyncio.run(sync_cmd(args))
201
+ elif args.command == "search":
202
+ asyncio.run(search_cmd(args))
203
+ elif args.command == "api":
204
+ asyncio.run(api_cmd(args))
205
+ except Exception as e:
206
+ if args.json:
207
+ print(json.dumps({"error": str(e)}))
208
+ else:
209
+ logger.error(e)
210
+ sys.exit(1)
211
+
212
+
213
+ if __name__ == "__main__":
214
+ main()
File without changes
@@ -0,0 +1,80 @@
1
+ import logging
2
+ import json
3
+ import litellm
4
+ from pydantic import BaseModel
5
+ from ..core.interfaces import IIntelligence
6
+ from .embedder import get_global_dir
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+ class DistillerConfig(BaseModel):
11
+ model: str = "auto"
12
+ api_base: str = "http://localhost:8081/api/v1"
13
+ api_key: str = "sk-not-required"
14
+ provider: str = "openai"
15
+ temperature: float = 0.0
16
+
17
+ @classmethod
18
+ def load(cls) -> "DistillerConfig":
19
+ """Loads config from the global agent-coderag directory."""
20
+ config_path = get_global_dir() / "config.json"
21
+ if config_path.exists():
22
+ try:
23
+ with open(config_path, "r", encoding="utf-8") as f:
24
+ data = json.load(f)
25
+ return cls(**data)
26
+ except Exception as e:
27
+ logger.error("Failed to load config from %s: %s", config_path, e)
28
+ return cls()
29
+
30
+ def save(self):
31
+ """Saves current config to the global agent-coderag directory."""
32
+ config_path = get_global_dir() / "config.json"
33
+ config_path.parent.mkdir(parents=True, exist_ok=True)
34
+ try:
35
+ with open(config_path, "w", encoding="utf-8") as f:
36
+ json.dump(self.model_dump(), f, indent=4)
37
+ logger.info("Config saved to %s", config_path)
38
+ except Exception as e:
39
+ logger.error("Failed to save config to %s: %s", config_path, e)
40
+
41
+ class Distiller(IIntelligence):
42
+ """
43
+ LLM-based code analyst that extracts the 'intent' from raw code.
44
+ """
45
+
46
+ def __init__(self, config: DistillerConfig):
47
+ self.config = config
48
+
49
+ async def summarize(self, code: str, unit_name: str) -> str:
50
+ """
51
+ Generates a concise technical summary of what the code DOES.
52
+ """
53
+ prompt = f"""
54
+ Analyze the following code block for '{unit_name}'.
55
+ Provide a concise, 1-2 sentence technical description of its core logic and intent.
56
+ Focus on WHAT it accomplishes and its role in the system.
57
+ DO NOT repeat the signature.
58
+ DO NOT include docstrings or comments in your summary.
59
+
60
+ CODE:
61
+ {code}
62
+
63
+ SUMMARY:
64
+ """
65
+ model_id = self.config.model
66
+ if self.config.provider == "ollama" and not model_id.startswith("ollama/"):
67
+ model_id = f"ollama/{model_id}"
68
+
69
+ response = await litellm.acompletion(
70
+ model=model_id,
71
+ messages=[{"role": "user", "content": prompt}],
72
+ api_base=self.config.api_base,
73
+ api_key=self.config.api_key,
74
+ temperature=self.config.temperature,
75
+ timeout=30,
76
+ custom_llm_provider=self.config.provider
77
+ )
78
+
79
+ summary = response.choices[0].message.content.strip()
80
+ return summary
@@ -0,0 +1,100 @@
1
+ import os
2
+ import logging
3
+ import numpy as np
4
+ from typing import List, Optional
5
+ from pathlib import Path
6
+ import onnxruntime as ort
7
+ from tokenizers import Tokenizer
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+ def get_global_dir() -> Path:
12
+ """Returns the default global directory for agent-coderag data (cross-platform)."""
13
+ if os.name == 'nt': # Windows
14
+ base_dir = Path(os.environ.get('LOCALAPPDATA', Path.home() / 'AppData' / 'Local'))
15
+ else: # Linux/macOS
16
+ base_dir = Path(os.environ.get('XDG_CACHE_HOME', Path.home() / '.cache'))
17
+
18
+ return base_dir / "agent-coderag"
19
+
20
+ def get_default_model_dir() -> Path:
21
+ """Returns the default global directory for models."""
22
+ return get_global_dir() / "models" / "mini-lm"
23
+
24
+ class Embedder:
25
+ """
26
+ Local multilingual embedder using ONNX Runtime and Tokenizers.
27
+ """
28
+
29
+ def __init__(self, model_path: Optional[str] = None):
30
+ self.model_path = model_path
31
+ self.session: Optional[ort.InferenceSession] = None
32
+ self.tokenizer: Optional[Tokenizer] = None
33
+
34
+ if not self.model_path:
35
+ global_dir = get_default_model_dir()
36
+ potential_path = global_dir / "model.onnx"
37
+ if potential_path.exists():
38
+ self.model_path = str(potential_path)
39
+ logger.info("Using global model from %s", self.model_path)
40
+ else:
41
+ logger.warning("No model found at %s. Please run 'agent-coderag setup'.", potential_path)
42
+
43
+ if self.model_path and os.path.exists(self.model_path):
44
+ self._init_tokenizer()
45
+ self._init_session()
46
+
47
+ def _init_tokenizer(self):
48
+ if not self.model_path:
49
+ return
50
+
51
+ model_dir = os.path.dirname(self.model_path)
52
+ tokenizer_file = os.path.join(model_dir, "tokenizer.json")
53
+ if not os.path.exists(tokenizer_file):
54
+ tokenizer_file = os.path.join(os.path.dirname(model_dir), "tokenizer.json")
55
+
56
+ if os.path.exists(tokenizer_file):
57
+ try:
58
+ self.tokenizer = Tokenizer.from_file(tokenizer_file)
59
+ self.tokenizer.enable_padding(pad_id=0, pad_token="[PAD]") # nosec B106
60
+ self.tokenizer.enable_truncation(max_length=512)
61
+ logger.debug("Loaded tokenizer from %s", tokenizer_file)
62
+ except Exception as e:
63
+ logger.error("Failed to load tokenizer from %s: %s", tokenizer_file, e)
64
+ else:
65
+ logger.error("tokenizer.json not found near %s", self.model_path)
66
+
67
+ def _init_session(self):
68
+ if not self.model_path:
69
+ return
70
+
71
+ try:
72
+ self.session = ort.InferenceSession(self.model_path, providers=['CPUExecutionProvider'])
73
+ logger.info("ONNX session initialized with model: %s", self.model_path)
74
+ except Exception as e:
75
+ logger.error("Failed to initialize ONNX session: %s", e)
76
+
77
+ def embed(self, texts: List[str]) -> np.ndarray:
78
+ if not self.session or not self.tokenizer:
79
+ return np.zeros((len(texts), 384), dtype=np.float32)
80
+
81
+ encodings = self.tokenizer.encode_batch(texts)
82
+ input_ids = np.array([e.ids for e in encodings], dtype=np.int64)
83
+ attention_mask = np.array([e.attention_mask for e in encodings], dtype=np.int64)
84
+
85
+ inputs = {"input_ids": input_ids, "attention_mask": attention_mask}
86
+ model_inputs = [i.name for i in self.session.get_inputs()]
87
+ if "token_type_ids" in model_inputs:
88
+ inputs["token_type_ids"] = np.array([e.type_ids for e in encodings], dtype=np.int64)
89
+
90
+ outputs = self.session.run(None, inputs)
91
+ embeddings = self._mean_pooling(outputs[0], attention_mask)
92
+ norms = np.linalg.norm(embeddings, axis=1, keepdims=True)
93
+ norms = np.clip(norms, a_min=1e-12, a_max=None)
94
+ return embeddings / norms
95
+
96
+ def _mean_pooling(self, last_hidden_state, attention_mask):
97
+ input_mask_expanded = np.expand_dims(attention_mask, -1).astype(float)
98
+ sum_embeddings = np.sum(last_hidden_state * input_mask_expanded, axis=1)
99
+ sum_mask = np.clip(input_mask_expanded.sum(axis=1), a_min=1e-9, a_max=None)
100
+ return sum_embeddings / sum_mask
File without changes
@@ -0,0 +1,81 @@
1
+ import ast
2
+ import hashlib
3
+ import os
4
+ import logging
5
+ from typing import List, Optional
6
+ from ..core.interfaces import IParser
7
+ from ..core.models import KnowledgeUnit, UnitKind
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+ class AstIndexParser(IParser):
12
+ """
13
+ Parses Python code using the built-in AST module to extract units.
14
+ """
15
+
16
+ async def distill_file(self, file_path: str) -> List[KnowledgeUnit]:
17
+ """
18
+ Parses a file and returns a list of knowledge units.
19
+ """
20
+ if not os.path.exists(file_path):
21
+ return []
22
+
23
+ try:
24
+ with open(file_path, "r", encoding="utf-8") as f:
25
+ source = f.read()
26
+
27
+ tree = ast.parse(source)
28
+ units = []
29
+
30
+ # Module level unit
31
+ file_hash = hashlib.sha256(source.strip().encode()).hexdigest()
32
+ units.append(KnowledgeUnit(
33
+ id=f"{file_path}:module",
34
+ kind=UnitKind.MODULE,
35
+ name=os.path.basename(file_path),
36
+ path=file_path,
37
+ code_hash=file_hash,
38
+ metadata={"raw_code": source}
39
+ ))
40
+
41
+ for node in ast.walk(tree):
42
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
43
+ unit = self._parse_node(node, file_path, source)
44
+ if unit:
45
+ units.append(unit)
46
+
47
+ return units
48
+ except Exception as e:
49
+ logger.error("Failed to parse %s: %s", file_path, e)
50
+ return []
51
+
52
+ def _parse_node(self, node, file_path: str, source: str) -> Optional[KnowledgeUnit]:
53
+ """Helper to create a KnowledgeUnit from an AST node."""
54
+ kind = UnitKind.FUNCTION
55
+ if isinstance(node, ast.ClassDef):
56
+ kind = UnitKind.CLASS
57
+ elif hasattr(node, "parent") and isinstance(node.parent, ast.ClassDef):
58
+ kind = UnitKind.METHOD
59
+
60
+ # Extract source code for the node
61
+ try:
62
+ node_source = ast.get_source_segment(source, node) or ""
63
+ node_hash = hashlib.sha256(node_source.strip().encode()).hexdigest()
64
+
65
+ # Simplified signature extraction
66
+ signature = None
67
+ if hasattr(node, "args"):
68
+ args = [arg.arg for arg in node.args.args]
69
+ signature = f"({', '.join(args)})"
70
+
71
+ return KnowledgeUnit(
72
+ id=f"{file_path}:{node.name}",
73
+ kind=kind,
74
+ name=node.name,
75
+ path=file_path,
76
+ signature=signature,
77
+ code_hash=node_hash,
78
+ metadata={"raw_code": node_source}
79
+ )
80
+ except Exception:
81
+ return None
File without changes
@@ -0,0 +1,111 @@
1
+ import json
2
+ import logging
3
+ import duckdb
4
+ from typing import List, Optional
5
+ from ..core.interfaces import IStorage
6
+ from ..core.models import KnowledgeUnit, UnitKind
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+ class DuckDBStorage(IStorage):
11
+ """
12
+ DuckDB-based storage with Vector Similarity Search (VSS) capabilities.
13
+ """
14
+
15
+ def __init__(self, db_path: str, embedder=None):
16
+ self.db_path = db_path
17
+ self.embedder = embedder
18
+ self.conn = duckdb.connect(self.db_path)
19
+ self._setup_db()
20
+
21
+ def _setup_db(self):
22
+ """Initializes tables and extensions."""
23
+ self.conn.execute("INSTALL vss;")
24
+ self.conn.execute("LOAD vss;")
25
+
26
+ # Metadata table
27
+ self.conn.execute("""
28
+ CREATE TABLE IF NOT EXISTS units (
29
+ id VARCHAR PRIMARY KEY,
30
+ kind VARCHAR,
31
+ name VARCHAR,
32
+ path VARCHAR,
33
+ signature VARCHAR,
34
+ summary VARCHAR,
35
+ code_hash VARCHAR,
36
+ tags VARCHAR[],
37
+ metadata JSON
38
+ )
39
+ """)
40
+
41
+ # Vector table (MiniLM dimension is 384)
42
+ self.conn.execute("""
43
+ CREATE TABLE IF NOT EXISTS unit_embeddings (
44
+ id VARCHAR PRIMARY KEY,
45
+ vec FLOAT[384]
46
+ )
47
+ """)
48
+ logger.info("Storage initialized at %s", self.db_path)
49
+
50
+ async def upsert_unit(self, unit: KnowledgeUnit):
51
+ """Inserts or updates a knowledge unit and its embedding."""
52
+ # 1. Upsert metadata
53
+ self.conn.execute("""
54
+ INSERT OR REPLACE INTO units (id, kind, name, path, signature, summary, code_hash, tags, metadata)
55
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
56
+ """, [
57
+ unit.id, unit.kind.value, unit.name, unit.path,
58
+ unit.signature, unit.summary, unit.code_hash,
59
+ unit.tags, json.dumps(unit.metadata)
60
+ ])
61
+
62
+ # 2. Update embedding
63
+ if self.embedder:
64
+ # Fallback to name/signature if summary is missing
65
+ text_to_embed = unit.summary or f"{unit.kind.value} {unit.name} {unit.signature or ''}"
66
+ vec = self.embedder.embed([text_to_embed])[0]
67
+ self.conn.execute("""
68
+ INSERT OR REPLACE INTO unit_embeddings (id, vec)
69
+ VALUES (?, ?)
70
+ """, [unit.id, vec.tolist()])
71
+
72
+ async def get_unit(self, unit_id: str) -> Optional[KnowledgeUnit]:
73
+ """Retrieves a unit by its unique ID."""
74
+ res = self.conn.execute("SELECT * FROM units WHERE id = ?", [unit_id]).fetchone()
75
+ if not res:
76
+ return None
77
+ return self._map_row_to_unit(res)
78
+
79
+ async def search_units(self, query: str, limit: int = 5) -> List[KnowledgeUnit]:
80
+ """Hybrid search using VSS and FTS."""
81
+ if not self.embedder:
82
+ # Fallback to basic text search if no embedder
83
+ res = self.conn.execute("""
84
+ SELECT * FROM units
85
+ WHERE name ILIKE ? OR summary ILIKE ?
86
+ LIMIT ?
87
+ """, [f"%{query}%", f"%{query}%", limit]).fetchall()
88
+ else:
89
+ query_vec = self.embedder.embed([query])[0]
90
+ res = self.conn.execute("""
91
+ SELECT u.*, array_distance(e.vec, ?::FLOAT[384]) as dist
92
+ FROM units u
93
+ JOIN unit_embeddings e ON u.id = e.id
94
+ ORDER BY dist ASC
95
+ LIMIT ?
96
+ """, [query_vec.tolist(), limit]).fetchall()
97
+
98
+ return [self._map_row_to_unit(row) for row in res]
99
+
100
+ def _map_row_to_unit(self, row) -> KnowledgeUnit:
101
+ return KnowledgeUnit(
102
+ id=row[0],
103
+ kind=UnitKind(row[1]),
104
+ name=row[2],
105
+ path=row[3],
106
+ signature=row[4],
107
+ summary=row[5],
108
+ code_hash=row[6],
109
+ tags=row[7] if row[7] else [],
110
+ metadata=json.loads(row[8]) if row[8] else {}
111
+ )