velune-cli 1.0.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.
Files changed (229) hide show
  1. velune/__init__.py +5 -0
  2. velune/__main__.py +6 -0
  3. velune/cli/__init__.py +5 -0
  4. velune/cli/app.py +212 -0
  5. velune/cli/autocomplete.py +76 -0
  6. velune/cli/banner.py +98 -0
  7. velune/cli/commands/__init__.py +32 -0
  8. velune/cli/commands/ask.py +149 -0
  9. velune/cli/commands/base.py +16 -0
  10. velune/cli/commands/chat.py +188 -0
  11. velune/cli/commands/config.py +182 -0
  12. velune/cli/commands/daemon.py +85 -0
  13. velune/cli/commands/doctor.py +373 -0
  14. velune/cli/commands/init.py +160 -0
  15. velune/cli/commands/mcp.py +80 -0
  16. velune/cli/commands/memory.py +269 -0
  17. velune/cli/commands/models.py +462 -0
  18. velune/cli/commands/preflight.py +95 -0
  19. velune/cli/commands/run.py +171 -0
  20. velune/cli/commands/setup.py +182 -0
  21. velune/cli/commands/workspace.py +217 -0
  22. velune/cli/context.py +37 -0
  23. velune/cli/councilmodel_ui.py +171 -0
  24. velune/cli/display/council_view.py +240 -0
  25. velune/cli/display/memory_view.py +93 -0
  26. velune/cli/display/panels.py +35 -0
  27. velune/cli/display/progress.py +25 -0
  28. velune/cli/display/themes.py +21 -0
  29. velune/cli/main.py +15 -0
  30. velune/cli/model_selector.py +44 -0
  31. velune/cli/modes.py +86 -0
  32. velune/cli/pull_ui.py +118 -0
  33. velune/cli/registry.py +81 -0
  34. velune/cli/repl.py +1178 -0
  35. velune/cli/session_manager.py +69 -0
  36. velune/cli/slash_commands.py +37 -0
  37. velune/cognition/__init__.py +19 -0
  38. velune/cognition/arbitrator.py +216 -0
  39. velune/cognition/architecture.py +398 -0
  40. velune/cognition/council/__init__.py +47 -0
  41. velune/cognition/council/base.py +216 -0
  42. velune/cognition/council/challenger.py +70 -0
  43. velune/cognition/council/coder.py +79 -0
  44. velune/cognition/council/critic_agent.py +39 -0
  45. velune/cognition/council/critic_configs.py +111 -0
  46. velune/cognition/council/critics.py +41 -0
  47. velune/cognition/council/debate.py +44 -0
  48. velune/cognition/council/factory.py +140 -0
  49. velune/cognition/council/messages.py +53 -0
  50. velune/cognition/council/planner.py +119 -0
  51. velune/cognition/council/reviewer.py +72 -0
  52. velune/cognition/council/synthesizer.py +67 -0
  53. velune/cognition/council/tiers.py +181 -0
  54. velune/cognition/firewall.py +256 -0
  55. velune/cognition/module.py +38 -0
  56. velune/cognition/orchestrator.py +886 -0
  57. velune/cognition/personality.py +236 -0
  58. velune/cognition/style_resolver.py +62 -0
  59. velune/cognition/verification.py +201 -0
  60. velune/context/__init__.py +11 -0
  61. velune/context/extractive.py +94 -0
  62. velune/context/window.py +62 -0
  63. velune/core/__init__.py +89 -0
  64. velune/core/background.py +5 -0
  65. velune/core/config/__init__.py +37 -0
  66. velune/core/errors/__init__.py +53 -0
  67. velune/core/errors/execution.py +26 -0
  68. velune/core/errors/memory.py +21 -0
  69. velune/core/errors/orchestration.py +26 -0
  70. velune/core/errors/provider.py +31 -0
  71. velune/core/event_loop.py +30 -0
  72. velune/core/logging.py +83 -0
  73. velune/core/runtime.py +106 -0
  74. velune/core/task_registry.py +120 -0
  75. velune/core/trace.py +80 -0
  76. velune/core/types/__init__.py +48 -0
  77. velune/core/types/agent.py +49 -0
  78. velune/core/types/context.py +39 -0
  79. velune/core/types/inference.py +35 -0
  80. velune/core/types/memory.py +39 -0
  81. velune/core/types/model.py +64 -0
  82. velune/core/types/provider.py +35 -0
  83. velune/core/types/repository.py +35 -0
  84. velune/core/types/task.py +56 -0
  85. velune/core/types/workspace.py +28 -0
  86. velune/daemon/client.py +13 -0
  87. velune/daemon/server.py +115 -0
  88. velune/daemon/transport.py +169 -0
  89. velune/events.py +194 -0
  90. velune/execution/__init__.py +22 -0
  91. velune/execution/benchmarker.py +311 -0
  92. velune/execution/cancellation.py +53 -0
  93. velune/execution/checkpointer.py +128 -0
  94. velune/execution/command_spec.py +140 -0
  95. velune/execution/diff_preview.py +172 -0
  96. velune/execution/executor.py +173 -0
  97. velune/execution/module.py +16 -0
  98. velune/execution/multi_diff.py +70 -0
  99. velune/execution/path_guard.py +19 -0
  100. velune/execution/planner.py +91 -0
  101. velune/execution/rollback.py +80 -0
  102. velune/execution/sandbox.py +257 -0
  103. velune/execution/validator.py +113 -0
  104. velune/hardware/__init__.py +1 -0
  105. velune/hardware/detector.py +162 -0
  106. velune/kernel/__init__.py +58 -0
  107. velune/kernel/bootstrap.py +107 -0
  108. velune/kernel/config.py +252 -0
  109. velune/kernel/health.py +54 -0
  110. velune/kernel/lifecycle.py +102 -0
  111. velune/kernel/module.py +15 -0
  112. velune/kernel/modules.py +23 -0
  113. velune/kernel/registry.py +93 -0
  114. velune/kernel/schemas.py +28 -0
  115. velune/main.py +9 -0
  116. velune/mcp/__init__.py +9 -0
  117. velune/mcp/client.py +113 -0
  118. velune/mcp/config.py +19 -0
  119. velune/mcp/server.py +90 -0
  120. velune/memory/__init__.py +28 -0
  121. velune/memory/lifecycle.py +154 -0
  122. velune/memory/module.py +94 -0
  123. velune/memory/prioritizer.py +65 -0
  124. velune/memory/storage/sqlite_manager.py +368 -0
  125. velune/memory/tiers/episodic.py +156 -0
  126. velune/memory/tiers/graph.py +282 -0
  127. velune/memory/tiers/lineage.py +367 -0
  128. velune/memory/tiers/semantic.py +198 -0
  129. velune/memory/tiers/working.py +165 -0
  130. velune/models/__init__.py +16 -0
  131. velune/models/module.py +18 -0
  132. velune/models/probes.py +182 -0
  133. velune/models/profile_cache.py +82 -0
  134. velune/models/profiler.py +105 -0
  135. velune/models/registry.py +201 -0
  136. velune/models/scorer.py +225 -0
  137. velune/models/specializations.py +196 -0
  138. velune/orchestration/__init__.py +15 -0
  139. velune/orchestration/module.py +14 -0
  140. velune/orchestration/role_assignments.py +78 -0
  141. velune/orchestration/schemas.py +99 -0
  142. velune/plugins/__init__.py +13 -0
  143. velune/plugins/hooks.py +49 -0
  144. velune/plugins/loader.py +95 -0
  145. velune/plugins/registry.py +54 -0
  146. velune/plugins/schemas.py +19 -0
  147. velune/providers/__init__.py +18 -0
  148. velune/providers/adapters/anthropic.py +231 -0
  149. velune/providers/adapters/fireworks.py +115 -0
  150. velune/providers/adapters/google.py +234 -0
  151. velune/providers/adapters/groq.py +151 -0
  152. velune/providers/adapters/huggingface.py +203 -0
  153. velune/providers/adapters/llamacpp.py +202 -0
  154. velune/providers/adapters/lmstudio.py +173 -0
  155. velune/providers/adapters/ollama.py +186 -0
  156. velune/providers/adapters/openai.py +207 -0
  157. velune/providers/adapters/openrouter.py +81 -0
  158. velune/providers/adapters/together.py +134 -0
  159. velune/providers/adapters/xai.py +60 -0
  160. velune/providers/base.py +86 -0
  161. velune/providers/benchmarker.py +135 -0
  162. velune/providers/discovery/__init__.py +33 -0
  163. velune/providers/discovery/anthropic.py +77 -0
  164. velune/providers/discovery/benchmarks.py +44 -0
  165. velune/providers/discovery/classifier.py +69 -0
  166. velune/providers/discovery/fireworks.py +95 -0
  167. velune/providers/discovery/gguf.py +87 -0
  168. velune/providers/discovery/google.py +95 -0
  169. velune/providers/discovery/gpu.py +109 -0
  170. velune/providers/discovery/groq.py +20 -0
  171. velune/providers/discovery/huggingface.py +67 -0
  172. velune/providers/discovery/lmstudio.py +80 -0
  173. velune/providers/discovery/ollama.py +165 -0
  174. velune/providers/discovery/openai.py +96 -0
  175. velune/providers/discovery/openrouter.py +113 -0
  176. velune/providers/discovery/scanner.py +114 -0
  177. velune/providers/discovery/together.py +114 -0
  178. velune/providers/discovery/xai.py +57 -0
  179. velune/providers/keystore.py +83 -0
  180. velune/providers/local_paths.py +49 -0
  181. velune/providers/local_resolver.py +208 -0
  182. velune/providers/module.py +15 -0
  183. velune/providers/ollama_manager.py +193 -0
  184. velune/providers/registry.py +173 -0
  185. velune/providers/router.py +82 -0
  186. velune/py.typed +0 -0
  187. velune/repository/__init__.py +21 -0
  188. velune/repository/analyzer.py +123 -0
  189. velune/repository/cognition.py +172 -0
  190. velune/repository/grapher.py +182 -0
  191. velune/repository/indexer.py +229 -0
  192. velune/repository/module.py +15 -0
  193. velune/repository/parser.py +378 -0
  194. velune/repository/project_type.py +293 -0
  195. velune/repository/scanner.py +177 -0
  196. velune/repository/schemas.py +102 -0
  197. velune/repository/tracker.py +233 -0
  198. velune/retrieval/__init__.py +27 -0
  199. velune/retrieval/graph.py +117 -0
  200. velune/retrieval/hybrid.py +250 -0
  201. velune/retrieval/keyword.py +113 -0
  202. velune/retrieval/module.py +19 -0
  203. velune/retrieval/reranker.py +68 -0
  204. velune/retrieval/schemas.py +59 -0
  205. velune/retrieval/vector.py +163 -0
  206. velune/telemetry/__init__.py +7 -0
  207. velune/telemetry/cognition.py +260 -0
  208. velune/telemetry/token_tracker.py +133 -0
  209. velune/tools/__init__.py +41 -0
  210. velune/tools/base/registry.py +84 -0
  211. velune/tools/base/tool.py +63 -0
  212. velune/tools/code/navigate.py +107 -0
  213. velune/tools/code/search.py +112 -0
  214. velune/tools/filesystem/read.py +75 -0
  215. velune/tools/filesystem/search.py +123 -0
  216. velune/tools/filesystem/write.py +160 -0
  217. velune/tools/git/history.py +185 -0
  218. velune/tools/git/operations.py +132 -0
  219. velune/tools/git/state.py +134 -0
  220. velune/tools/module.py +65 -0
  221. velune/tools/terminal/execute.py +72 -0
  222. velune/tools/terminal/history.py +47 -0
  223. velune/tools/web/fetch.py +55 -0
  224. velune/tools/web/validator.py +96 -0
  225. velune_cli-1.0.0.dist-info/METADATA +497 -0
  226. velune_cli-1.0.0.dist-info/RECORD +229 -0
  227. velune_cli-1.0.0.dist-info/WHEEL +4 -0
  228. velune_cli-1.0.0.dist-info/entry_points.txt +2 -0
  229. velune_cli-1.0.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,233 @@
1
+ """Git tracking, commit history, and code blame metrics."""
2
+
3
+ import subprocess
4
+ from pathlib import Path
5
+
6
+
7
+ class GitTracker:
8
+ """Direct Git integration for capturing branch topology, blames, and commit volatility."""
9
+
10
+ def __init__(self, root_path: Path) -> None:
11
+ self.root_path = root_path.resolve()
12
+ self.is_git = (self.root_path / ".git").exists()
13
+
14
+ def get_active_branch(self) -> str:
15
+ """Returns the name of the currently checked out Git branch."""
16
+ if not self.is_git:
17
+ return "non-git"
18
+ try:
19
+ res = self._run_git(["rev-parse", "--abbrev-ref", "HEAD"])
20
+ return res.strip()
21
+ except Exception:
22
+ return "unknown"
23
+
24
+ def get_uncommitted_changes(self) -> list[str]:
25
+ """Lists all unstaged, staged, or untracked changes in the workspace."""
26
+ if not self.is_git:
27
+ return []
28
+ try:
29
+ res = self._run_git(["status", "--porcelain"])
30
+ changes = []
31
+ for line in res.splitlines():
32
+ if len(line) > 3:
33
+ # Status code is first two characters, then space, then file path
34
+ changes.append(line[3:].strip())
35
+ return changes
36
+ except Exception:
37
+ return []
38
+
39
+ def get_recent_commits(self, limit: int = 10) -> list[dict[str, str]]:
40
+ """Retrieves a list of recent commits with metadata."""
41
+ if not self.is_git:
42
+ return []
43
+ try:
44
+ # Format: hash | author | date | subject
45
+ res = self._run_git(["log", "-n", str(limit), "--pretty=format:%H|%an|%ad|%s", "--date=short"])
46
+ commits = []
47
+ for line in res.splitlines():
48
+ parts = line.split("|")
49
+ if len(parts) >= 4:
50
+ commits.append({
51
+ "hash": parts[0],
52
+ "author": parts[1],
53
+ "date": parts[2],
54
+ "subject": parts[3]
55
+ })
56
+ return commits
57
+ except Exception:
58
+ return []
59
+
60
+ def get_file_volatility(self, file_path: str, days: int = 90) -> int:
61
+ """Calculates commit volatility (number of times modified in Git) over a period."""
62
+ if not self.is_git:
63
+ return 0
64
+ try:
65
+ # Count commit entries modifying this file
66
+ res = self._run_git(["log", f"--since={days} days ago", "--oneline", "--", file_path])
67
+ return len(res.splitlines())
68
+ except Exception:
69
+ return 0
70
+
71
+ def get_blame(self, file_path: str) -> list[dict[str, str]]:
72
+ """Parses git blame details to index code line ownership and recency."""
73
+ if not self.is_git:
74
+ return []
75
+ try:
76
+ # git blame --porcelain file
77
+ res = self._run_git(["blame", "--porcelain", file_path])
78
+ blames = []
79
+ commit_data: dict[str, dict[str, str]] = {}
80
+ lines = res.splitlines()
81
+
82
+ i = 0
83
+ while i < len(lines):
84
+ line = lines[i]
85
+ parts = line.split()
86
+ if not parts:
87
+ i += 1
88
+ continue
89
+
90
+ sha = parts[0]
91
+ if sha not in commit_data:
92
+ # Parse commit info block
93
+ author = "unknown"
94
+ date = "unknown"
95
+ j = i + 1
96
+ while j < len(lines) and not lines[j].startswith("\t"):
97
+ if lines[j].startswith("author "):
98
+ author = lines[j][7:]
99
+ elif lines[j].startswith("author-time "):
100
+ date = lines[j][12:]
101
+ j += 1
102
+ commit_data[sha] = {"author": author, "date": date}
103
+
104
+ # Find line contents
105
+ j = i + 1
106
+ while j < len(lines) and not lines[j].startswith("\t"):
107
+ j += 1
108
+ if j < len(lines) and lines[j].startswith("\t"):
109
+ content = lines[j][1:]
110
+ blames.append({
111
+ "commit": sha,
112
+ "author": commit_data[sha]["author"],
113
+ "date": commit_data[sha]["date"],
114
+ "content": content
115
+ })
116
+ i = j + 1
117
+ return blames
118
+ except Exception:
119
+ return []
120
+
121
+ def create_stash(self, name: str = "velune-snapshot") -> bool:
122
+ """Stashes current uncommitted modifications to prepare for validation or rollback."""
123
+ if not self.is_git:
124
+ return False
125
+ try:
126
+ self._run_git(["stash", "push", "-m", name, "--include-untracked"])
127
+ return True
128
+ except Exception:
129
+ return False
130
+
131
+ def pop_stash(self) -> bool:
132
+ """Pops the last stashed state, restoring uncommitted changes."""
133
+ if not self.is_git:
134
+ return False
135
+ try:
136
+ self._run_git(["stash", "pop"])
137
+ return True
138
+ except Exception:
139
+ return False
140
+
141
+ def apply_stash(self) -> bool:
142
+ """Applies the last stashed state, keeping it in the stash list."""
143
+ if not self.is_git:
144
+ return False
145
+ try:
146
+ self._run_git(["stash", "apply"])
147
+ return True
148
+ except Exception:
149
+ return False
150
+
151
+ def drop_stash(self) -> bool:
152
+ """Drops the last stashed state from the stash list."""
153
+ if not self.is_git:
154
+ return False
155
+ try:
156
+ self._run_git(["stash", "drop"])
157
+ return True
158
+ except Exception:
159
+ return False
160
+
161
+ def _run_git(self, args: list[str]) -> str:
162
+ """Helper to safely execute git subprocess commands in the repository root."""
163
+ cmd = ["git"] + args
164
+ res = subprocess.run(
165
+ cmd,
166
+ cwd=self.root_path,
167
+ capture_output=True,
168
+ text=True,
169
+ check=True,
170
+ encoding="utf-8",
171
+ errors="ignore"
172
+ )
173
+ return res.stdout
174
+
175
+ async def _run_git_async(self, args: list[str]) -> str:
176
+ """Async version of _run_git using asyncio.to_thread."""
177
+ import asyncio
178
+ import functools
179
+ cmd = ["git"] + args
180
+ try:
181
+ result = await asyncio.to_thread(
182
+ functools.partial(
183
+ subprocess.run,
184
+ cmd,
185
+ cwd=self.root_path,
186
+ capture_output=True,
187
+ text=True,
188
+ check=True,
189
+ encoding="utf-8",
190
+ errors="ignore",
191
+ )
192
+ )
193
+ return result.stdout
194
+ except subprocess.CalledProcessError as e:
195
+ raise RuntimeError(f"Git command failed: {' '.join(cmd)}: {e.stderr}") from e
196
+
197
+ def get_all_file_volatility(self, days: int = 90) -> dict[str, int]:
198
+ """Get commit counts for ALL files in a single git log call.
199
+
200
+ Returns:
201
+ dict mapping relative file path → commit count in last {days} days.
202
+ Empty dict if not a git repo.
203
+ """
204
+ if not self.is_git:
205
+ return {}
206
+ try:
207
+ result = self._run_git([
208
+ "log",
209
+ f"--since={days} days ago",
210
+ "--pretty=format:",
211
+ "--name-only",
212
+ ])
213
+
214
+ counts: dict[str, int] = {}
215
+ for line in result.splitlines():
216
+ line = line.strip()
217
+ if line: # Skip empty lines (between commits)
218
+ # Normalize paths to use forward-slashes (consistent across all platforms)
219
+ normalized_line = line.replace("\\", "/")
220
+ counts[normalized_line] = counts.get(normalized_line, 0) + 1
221
+ return counts
222
+ except subprocess.CalledProcessError as e:
223
+ import logging
224
+ logging.getLogger("velune.repository.tracker").warning(
225
+ "Git log batch volatility failed with process error: %s", e
226
+ )
227
+ return {}
228
+ except Exception as e:
229
+ import logging
230
+ logging.getLogger("velune.repository.tracker").error(
231
+ "Unexpected error in get_all_file_volatility: %s", e
232
+ )
233
+ return {}
@@ -0,0 +1,27 @@
1
+ """Retrieval infrastructure for BM25, Qdrant vector, and graph traversals."""
2
+
3
+ from velune.retrieval.graph import GraphRetriever
4
+ from velune.retrieval.hybrid import HybridRetriever
5
+ from velune.retrieval.keyword import BM25Retriever
6
+ from velune.retrieval.reranker import ContextReranker
7
+ from velune.retrieval.schemas import (
8
+ RetrievalDocument,
9
+ RetrievalHit,
10
+ RetrievalQuery,
11
+ RetrievalResult,
12
+ RetrievalSource,
13
+ )
14
+ from velune.retrieval.vector import VectorRetriever
15
+
16
+ __all__ = [
17
+ "HybridRetriever",
18
+ "BM25Retriever",
19
+ "VectorRetriever",
20
+ "GraphRetriever",
21
+ "ContextReranker",
22
+ "RetrievalDocument",
23
+ "RetrievalHit",
24
+ "RetrievalQuery",
25
+ "RetrievalResult",
26
+ "RetrievalSource",
27
+ ]
@@ -0,0 +1,117 @@
1
+ """Knowledge and repository AST dependency graph traversal retriever."""
2
+
3
+ from velune.retrieval.schemas import RetrievalDocument, RetrievalHit, RetrievalSource
4
+
5
+
6
+ class GraphRetriever:
7
+ """Traverses knowledge graphs and repository AST structures to fetch contiguous context.
8
+
9
+ This retriever uses the **cached** repository snapshot (read from the
10
+ on-disk JSON index) instead of triggering a full ``repo_service.index()``
11
+ on every call. That eliminates the O(N·files) AST re-parse that
12
+ previously happened on every retrieval request.
13
+
14
+ Cold-start behaviour (no cache on disk yet):
15
+ - ``get_snapshot()`` returns ``None``.
16
+ - ``retrieve()`` returns an empty list and logs a debug warning.
17
+ - The first ``velune run`` / ``velune chat`` call that triggers a full
18
+ index will populate the cache, after which graph retrieval works normally.
19
+ """
20
+
21
+ def retrieve(self, node_id: str, depth: int = 1, top_k: int = 10) -> list[RetrievalHit]:
22
+ """Traverses adjacent AST and symbol imports from the repository cognition service.
23
+
24
+ Uses the cached snapshot via ``get_snapshot()`` to avoid triggering a
25
+ full repository re-index on every call.
26
+ """
27
+ hits: list[RetrievalHit] = []
28
+
29
+ try:
30
+ # Resolve the active RepositoryCognitionService from the kernel container
31
+ try:
32
+ from velune.kernel.registry import get_container
33
+ repo_service = get_container().get("runtime.repository_cognition")
34
+ except (KeyError, Exception):
35
+ return []
36
+
37
+ if not repo_service:
38
+ return []
39
+
40
+ # Traverse neighboring nodes in the import/dependency call graph
41
+ neighbors = repo_service.traverse(node_id, depth=depth)
42
+
43
+ # Remove self from traversal to avoid duplicates
44
+ norm_node = node_id.replace("\\", "/")
45
+ neighbors = [n for n in neighbors if n != norm_node]
46
+
47
+ # --- KEY FIX: use get_snapshot() instead of index() ---
48
+ # get_snapshot() reads the on-disk JSON cache written by the last
49
+ # full index run. It does NOT re-parse ASTs or run Git commands.
50
+ # Falls back gracefully to empty lists when no cache exists yet.
51
+ snapshot = repo_service.get_snapshot()
52
+ if snapshot is None:
53
+ # Cold start — no index on disk yet; skip graph retrieval silently
54
+ return []
55
+
56
+ file_map = {f.path: f for f in snapshot.files}
57
+ symbol_by_id = {s.symbol_id: s for s in snapshot.symbols if s.symbol_id}
58
+ symbol_by_qualified = {s.qualified_name: s for s in snapshot.symbols if s.qualified_name}
59
+ symbol_by_name = {s.name: s for s in snapshot.symbols}
60
+
61
+ rank = 1
62
+ for n in neighbors[:top_k]:
63
+ content = ""
64
+ metadata = {}
65
+ s = None
66
+
67
+ # Check if neighbor is a file path
68
+ if n in file_map:
69
+ f = file_map[n]
70
+ metadata = {
71
+ "path": f.path,
72
+ "language": f.language.value,
73
+ "size_bytes": f.size_bytes,
74
+ "sha256": f.sha256
75
+ }
76
+ content = f"File: {f.path}\nLanguage: {f.language.value}\nSymbols: " + ", ".join(sym.name for sym in f.symbols)
77
+ # Check if neighbor is a symbol (by symbol_id, qualified_name, or name)
78
+ elif n in symbol_by_id:
79
+ s = symbol_by_id[n]
80
+ elif n in symbol_by_qualified:
81
+ s = symbol_by_qualified[n]
82
+ elif n in symbol_by_name:
83
+ s = symbol_by_name[n]
84
+
85
+ if s:
86
+ metadata = {
87
+ "name": s.name,
88
+ "kind": s.kind.value,
89
+ "file_path": s.file_path,
90
+ "parent": s.parent or "",
91
+ "symbol_id": s.symbol_id or "",
92
+ "qualified_name": s.qualified_name or ""
93
+ }
94
+ content = f"Symbol: {s.name}\nKind: {s.kind.value}\nDefined in: {s.file_path}\nLine range: {s.line_start}-{s.line_end}"
95
+ if s.docstring:
96
+ content += f"\nDocstring: {s.docstring}"
97
+
98
+ if content:
99
+ doc = RetrievalDocument(
100
+ id=f"graph-{n}",
101
+ content=content,
102
+ namespace="repository_graph",
103
+ metadata=metadata
104
+ )
105
+ hits.append(
106
+ RetrievalHit(
107
+ document=doc,
108
+ score=1.0 / (depth + 0.1), # Closer connections get higher heuristic weightings
109
+ source=RetrievalSource.GRAPH,
110
+ rank=rank
111
+ )
112
+ )
113
+ rank += 1
114
+ except Exception:
115
+ pass
116
+
117
+ return hits
@@ -0,0 +1,250 @@
1
+ import logging
2
+ import os
3
+ from typing import Any
4
+
5
+ logger = logging.getLogger("velune.retrieval.hybrid")
6
+
7
+
8
+ from velune.retrieval.graph import GraphRetriever
9
+ from velune.retrieval.keyword import BM25Retriever
10
+ from velune.retrieval.reranker import ContextReranker
11
+ from velune.retrieval.schemas import RetrievalHit, RetrievalQuery, RetrievalResult
12
+ from velune.retrieval.vector import VectorRetriever
13
+
14
+
15
+ class HybridRetriever:
16
+ """Orchestrates fusion retrieval, combining Lexical, Vector, and Graph traversals.
17
+
18
+ Primary interface: await retrieve(). search() is sync-only.
19
+ """
20
+
21
+ def __init__(self, location: str = ":memory:", client: Any | None = None) -> None:
22
+ self.vector_retriever = VectorRetriever(location=location, client=client)
23
+ self.lexical_retriever = BM25Retriever()
24
+ self.graph_retriever = GraphRetriever()
25
+ self.reranker = ContextReranker()
26
+
27
+ def add_documents(self, docs: list[Any]) -> None:
28
+ """Adds and indexes documents in both vector and lexical subsystems.
29
+
30
+ All documents must have a pre-computed embedding.
31
+ """
32
+ # Index in Lexical (BM25)
33
+ self.lexical_retriever.add_documents(docs)
34
+
35
+ # Index in Vector (Qdrant)
36
+ for doc in docs:
37
+ # Require embedding to be pre-computed (make embedding field required, not optional)
38
+ if not doc.embedding:
39
+ raise ValueError(
40
+ f"Document {doc.id} must have a pre-computed embedding. "
41
+ "All callers of add_documents() must pre-compute embeddings using await before calling."
42
+ )
43
+ self.vector_retriever.upsert(doc)
44
+
45
+ async def retrieve(self, query: RetrievalQuery) -> RetrievalResult:
46
+ """Performs full hybrid retrieval, merges candidate pools, and reranks."""
47
+ lexical_hits: list[RetrievalHit] = []
48
+ vector_hits: list[RetrievalHit] = []
49
+ graph_hits: list[RetrievalHit] = []
50
+
51
+ # 1. Execute Lexical search (BM25)
52
+ if query.lexical_weight > 0.0:
53
+ try:
54
+ lexical_hits = self.lexical_retriever.retrieve(
55
+ query.text, top_k=query.top_k, namespace=query.namespace
56
+ )
57
+ except Exception:
58
+ pass
59
+
60
+ # 2. Execute Vector search (Qdrant)
61
+ if query.vector_weight > 0.0:
62
+ try:
63
+ # Generate embedding for the query
64
+ emb = await self._generate_embedding_async(query.text)
65
+ if emb is not None: # Only do vector search if real embedding
66
+ vector_hits = self.vector_retriever.retrieve(
67
+ emb, top_k=query.top_k, namespace=query.namespace
68
+ )
69
+ else:
70
+ logger.info("Vector retrieval skipped — no embedding available")
71
+ except Exception:
72
+ pass
73
+
74
+ # 3. Execute Graph traversal search
75
+ # If we have hits from lexical or vector search, traverse neighboring file links
76
+ if query.graph_weight > 0.0:
77
+ seed_nodes = []
78
+ # Gather file path candidates
79
+ for hit in lexical_hits[:3] + vector_hits[:3]:
80
+ path = hit.document.metadata.get("path")
81
+ if path:
82
+ seed_nodes.append(path)
83
+ name = hit.document.metadata.get("name")
84
+ if name:
85
+ seed_nodes.append(name)
86
+
87
+ for node in set(seed_nodes):
88
+ try:
89
+ gh = self.graph_retriever.retrieve(node, depth=1, top_k=5)
90
+ graph_hits.extend(gh)
91
+ except Exception:
92
+ pass
93
+
94
+ # 4. Fusion and Deduplication
95
+ merged_hits_map: dict[str, RetrievalHit] = {}
96
+
97
+ # Helper to blend weights into score
98
+ def merge_hit(hit: RetrievalHit, weight: float) -> None:
99
+ doc_id = hit.document.id
100
+ weighted_score = hit.score * weight
101
+
102
+ if doc_id in merged_hits_map:
103
+ # Combine scores from multiple search strategies
104
+ existing = merged_hits_map[doc_id]
105
+ existing.score += weighted_score
106
+ else:
107
+ hit.score = weighted_score
108
+ merged_hits_map[doc_id] = hit
109
+
110
+ for h in lexical_hits:
111
+ merge_hit(h, query.lexical_weight)
112
+ for h in vector_hits:
113
+ merge_hit(h, query.vector_weight)
114
+ for h in graph_hits:
115
+ merge_hit(h, query.graph_weight)
116
+
117
+ all_hits = list(merged_hits_map.values())
118
+
119
+ # 5. Rerank final combined candidates
120
+ reranked_hits = self.reranker.rerank(query.text, all_hits)
121
+
122
+ return RetrievalResult(
123
+ query=query,
124
+ hits=reranked_hits[:query.top_k],
125
+ strategy="hybrid-fusion-reranked"
126
+ )
127
+
128
+ def search_sync(self, query: RetrievalQuery) -> RetrievalResult:
129
+ """Synchronous retrieval. DEPRECATED: use await retrieve() in async contexts.
130
+
131
+ This method creates a new event loop for synchronous callers. Do NOT call
132
+ from within a running event loop — use 'await self.retrieve(query)' instead.
133
+
134
+ Raises:
135
+ RuntimeError: If called from within a running event loop.
136
+ """
137
+ import asyncio
138
+ import warnings
139
+ warnings.warn(
140
+ "HybridRetriever.search_sync() is deprecated and will be removed in a future version. "
141
+ "Use 'await retriever.retrieve(query)' instead.",
142
+ DeprecationWarning,
143
+ stacklevel=2,
144
+ )
145
+ try:
146
+ asyncio.get_running_loop()
147
+ raise RuntimeError(
148
+ "HybridRetriever.search_sync() cannot be called from an async context. "
149
+ "Use 'await retriever.retrieve(query)' instead."
150
+ )
151
+ except RuntimeError as e:
152
+ if "cannot be called" in str(e):
153
+ raise
154
+ # No running loop — safe to use asyncio.run()
155
+
156
+ return asyncio.run(self.retrieve(query))
157
+
158
+ def search(self, query: RetrievalQuery) -> RetrievalResult:
159
+ """Synchronous interface. Do NOT call from within a running event loop.
160
+ Use await retrieve() instead from async contexts."""
161
+ import asyncio
162
+ import warnings
163
+ try:
164
+ asyncio.get_running_loop()
165
+ warnings.warn(
166
+ "HybridRetriever.search() called from async context. "
167
+ "Use 'await retriever.retrieve()' instead.",
168
+ RuntimeWarning, stacklevel=2
169
+ )
170
+ except RuntimeError:
171
+ pass # No running loop, safe to use asyncio.run()
172
+ return asyncio.run(self.retrieve(query))
173
+
174
+ async def check_embedding_available(self) -> bool:
175
+ """Returns True if a real embedding provider is available."""
176
+ try:
177
+ test_emb = await self._generate_embedding_async("test")
178
+ return test_emb is not None
179
+ except Exception:
180
+ return False
181
+
182
+ def _deterministic_fallback_embedding(self, text: str) -> list[float]:
183
+ """Sophisticated deterministic fallback embedding vector."""
184
+ res = [0.0] * 1536
185
+ for idx, char in enumerate(text[:300]):
186
+ res[idx % 1536] += ord(char) / 256.0
187
+ return res
188
+
189
+ async def _generate_embedding_async(self, text: str) -> list[float] | None:
190
+ """Generates embedding asynchronously using the registered ModelProvider, or falls back to a deterministic vector.
191
+
192
+ INTERNAL ONLY.
193
+ """
194
+ try:
195
+ from velune.kernel.registry import get_container
196
+ container = get_container()
197
+ if container.has("runtime.provider_registry"):
198
+ provider_registry = container.get("runtime.provider_registry")
199
+ config = container.get("runtime.config") if container.has("runtime.config") else None
200
+
201
+ provider_name = "openai"
202
+ if config and hasattr(config, "providers") and config.providers:
203
+ provider_name = config.providers.default_provider
204
+
205
+ provider = provider_registry.get(provider_name)
206
+ if provider:
207
+ try:
208
+ caps = provider.get_capabilities()
209
+ if not caps.supports_embeddings:
210
+ logger.info(
211
+ "Provider %s does not support embeddings. Skipping vector embedding generation.",
212
+ provider_name
213
+ )
214
+ return None
215
+ except Exception as e:
216
+ logger.warning("Could not query capabilities for provider %s: %s", provider_name, e)
217
+
218
+ model_id = "text-embedding-3-small"
219
+ if provider_name == "ollama":
220
+ model_id = "nomic-embed-text"
221
+
222
+ res = await provider.embed([text], model_id=model_id)
223
+ emb = res[0] if res else None
224
+ if emb:
225
+ logger.debug("Generated embedding: dim=%d, provider=%s", len(emb), provider_name)
226
+ return emb
227
+ except Exception as e:
228
+ import logging
229
+ logging.getLogger("velune.retrieval.hybrid").warning(
230
+ "Failed to generate embedding using ModelProvider: %s. Falling back to deterministic embedding.", e
231
+ )
232
+
233
+ # No provider available
234
+ allow_fallback = os.environ.get("VELUNE_ALLOW_FALLBACK_EMBEDDING", "false").lower() == "true"
235
+ if allow_fallback:
236
+ logger.warning(
237
+ "Using character-frequency fallback embedding. "
238
+ "Semantic retrieval results will be degraded. "
239
+ "Install an embedding model (e.g., ollama pull nomic-embed-text) "
240
+ "or set OPENAI_API_KEY to enable real embeddings."
241
+ )
242
+ return self._deterministic_fallback_embedding(text)
243
+
244
+ logger.warning(
245
+ "No embedding provider available. Vector retrieval disabled. "
246
+ "Set VELUNE_ALLOW_FALLBACK_EMBEDDING=true to enable degraded mode."
247
+ )
248
+ return None
249
+
250
+