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.
- velune/__init__.py +5 -0
- velune/__main__.py +6 -0
- velune/cli/__init__.py +5 -0
- velune/cli/app.py +212 -0
- velune/cli/autocomplete.py +76 -0
- velune/cli/banner.py +98 -0
- velune/cli/commands/__init__.py +32 -0
- velune/cli/commands/ask.py +149 -0
- velune/cli/commands/base.py +16 -0
- velune/cli/commands/chat.py +188 -0
- velune/cli/commands/config.py +182 -0
- velune/cli/commands/daemon.py +85 -0
- velune/cli/commands/doctor.py +373 -0
- velune/cli/commands/init.py +160 -0
- velune/cli/commands/mcp.py +80 -0
- velune/cli/commands/memory.py +269 -0
- velune/cli/commands/models.py +462 -0
- velune/cli/commands/preflight.py +95 -0
- velune/cli/commands/run.py +171 -0
- velune/cli/commands/setup.py +182 -0
- velune/cli/commands/workspace.py +217 -0
- velune/cli/context.py +37 -0
- velune/cli/councilmodel_ui.py +171 -0
- velune/cli/display/council_view.py +240 -0
- velune/cli/display/memory_view.py +93 -0
- velune/cli/display/panels.py +35 -0
- velune/cli/display/progress.py +25 -0
- velune/cli/display/themes.py +21 -0
- velune/cli/main.py +15 -0
- velune/cli/model_selector.py +44 -0
- velune/cli/modes.py +86 -0
- velune/cli/pull_ui.py +118 -0
- velune/cli/registry.py +81 -0
- velune/cli/repl.py +1178 -0
- velune/cli/session_manager.py +69 -0
- velune/cli/slash_commands.py +37 -0
- velune/cognition/__init__.py +19 -0
- velune/cognition/arbitrator.py +216 -0
- velune/cognition/architecture.py +398 -0
- velune/cognition/council/__init__.py +47 -0
- velune/cognition/council/base.py +216 -0
- velune/cognition/council/challenger.py +70 -0
- velune/cognition/council/coder.py +79 -0
- velune/cognition/council/critic_agent.py +39 -0
- velune/cognition/council/critic_configs.py +111 -0
- velune/cognition/council/critics.py +41 -0
- velune/cognition/council/debate.py +44 -0
- velune/cognition/council/factory.py +140 -0
- velune/cognition/council/messages.py +53 -0
- velune/cognition/council/planner.py +119 -0
- velune/cognition/council/reviewer.py +72 -0
- velune/cognition/council/synthesizer.py +67 -0
- velune/cognition/council/tiers.py +181 -0
- velune/cognition/firewall.py +256 -0
- velune/cognition/module.py +38 -0
- velune/cognition/orchestrator.py +886 -0
- velune/cognition/personality.py +236 -0
- velune/cognition/style_resolver.py +62 -0
- velune/cognition/verification.py +201 -0
- velune/context/__init__.py +11 -0
- velune/context/extractive.py +94 -0
- velune/context/window.py +62 -0
- velune/core/__init__.py +89 -0
- velune/core/background.py +5 -0
- velune/core/config/__init__.py +37 -0
- velune/core/errors/__init__.py +53 -0
- velune/core/errors/execution.py +26 -0
- velune/core/errors/memory.py +21 -0
- velune/core/errors/orchestration.py +26 -0
- velune/core/errors/provider.py +31 -0
- velune/core/event_loop.py +30 -0
- velune/core/logging.py +83 -0
- velune/core/runtime.py +106 -0
- velune/core/task_registry.py +120 -0
- velune/core/trace.py +80 -0
- velune/core/types/__init__.py +48 -0
- velune/core/types/agent.py +49 -0
- velune/core/types/context.py +39 -0
- velune/core/types/inference.py +35 -0
- velune/core/types/memory.py +39 -0
- velune/core/types/model.py +64 -0
- velune/core/types/provider.py +35 -0
- velune/core/types/repository.py +35 -0
- velune/core/types/task.py +56 -0
- velune/core/types/workspace.py +28 -0
- velune/daemon/client.py +13 -0
- velune/daemon/server.py +115 -0
- velune/daemon/transport.py +169 -0
- velune/events.py +194 -0
- velune/execution/__init__.py +22 -0
- velune/execution/benchmarker.py +311 -0
- velune/execution/cancellation.py +53 -0
- velune/execution/checkpointer.py +128 -0
- velune/execution/command_spec.py +140 -0
- velune/execution/diff_preview.py +172 -0
- velune/execution/executor.py +173 -0
- velune/execution/module.py +16 -0
- velune/execution/multi_diff.py +70 -0
- velune/execution/path_guard.py +19 -0
- velune/execution/planner.py +91 -0
- velune/execution/rollback.py +80 -0
- velune/execution/sandbox.py +257 -0
- velune/execution/validator.py +113 -0
- velune/hardware/__init__.py +1 -0
- velune/hardware/detector.py +162 -0
- velune/kernel/__init__.py +58 -0
- velune/kernel/bootstrap.py +107 -0
- velune/kernel/config.py +252 -0
- velune/kernel/health.py +54 -0
- velune/kernel/lifecycle.py +102 -0
- velune/kernel/module.py +15 -0
- velune/kernel/modules.py +23 -0
- velune/kernel/registry.py +93 -0
- velune/kernel/schemas.py +28 -0
- velune/main.py +9 -0
- velune/mcp/__init__.py +9 -0
- velune/mcp/client.py +113 -0
- velune/mcp/config.py +19 -0
- velune/mcp/server.py +90 -0
- velune/memory/__init__.py +28 -0
- velune/memory/lifecycle.py +154 -0
- velune/memory/module.py +94 -0
- velune/memory/prioritizer.py +65 -0
- velune/memory/storage/sqlite_manager.py +368 -0
- velune/memory/tiers/episodic.py +156 -0
- velune/memory/tiers/graph.py +282 -0
- velune/memory/tiers/lineage.py +367 -0
- velune/memory/tiers/semantic.py +198 -0
- velune/memory/tiers/working.py +165 -0
- velune/models/__init__.py +16 -0
- velune/models/module.py +18 -0
- velune/models/probes.py +182 -0
- velune/models/profile_cache.py +82 -0
- velune/models/profiler.py +105 -0
- velune/models/registry.py +201 -0
- velune/models/scorer.py +225 -0
- velune/models/specializations.py +196 -0
- velune/orchestration/__init__.py +15 -0
- velune/orchestration/module.py +14 -0
- velune/orchestration/role_assignments.py +78 -0
- velune/orchestration/schemas.py +99 -0
- velune/plugins/__init__.py +13 -0
- velune/plugins/hooks.py +49 -0
- velune/plugins/loader.py +95 -0
- velune/plugins/registry.py +54 -0
- velune/plugins/schemas.py +19 -0
- velune/providers/__init__.py +18 -0
- velune/providers/adapters/anthropic.py +231 -0
- velune/providers/adapters/fireworks.py +115 -0
- velune/providers/adapters/google.py +234 -0
- velune/providers/adapters/groq.py +151 -0
- velune/providers/adapters/huggingface.py +203 -0
- velune/providers/adapters/llamacpp.py +202 -0
- velune/providers/adapters/lmstudio.py +173 -0
- velune/providers/adapters/ollama.py +186 -0
- velune/providers/adapters/openai.py +207 -0
- velune/providers/adapters/openrouter.py +81 -0
- velune/providers/adapters/together.py +134 -0
- velune/providers/adapters/xai.py +60 -0
- velune/providers/base.py +86 -0
- velune/providers/benchmarker.py +135 -0
- velune/providers/discovery/__init__.py +33 -0
- velune/providers/discovery/anthropic.py +77 -0
- velune/providers/discovery/benchmarks.py +44 -0
- velune/providers/discovery/classifier.py +69 -0
- velune/providers/discovery/fireworks.py +95 -0
- velune/providers/discovery/gguf.py +87 -0
- velune/providers/discovery/google.py +95 -0
- velune/providers/discovery/gpu.py +109 -0
- velune/providers/discovery/groq.py +20 -0
- velune/providers/discovery/huggingface.py +67 -0
- velune/providers/discovery/lmstudio.py +80 -0
- velune/providers/discovery/ollama.py +165 -0
- velune/providers/discovery/openai.py +96 -0
- velune/providers/discovery/openrouter.py +113 -0
- velune/providers/discovery/scanner.py +114 -0
- velune/providers/discovery/together.py +114 -0
- velune/providers/discovery/xai.py +57 -0
- velune/providers/keystore.py +83 -0
- velune/providers/local_paths.py +49 -0
- velune/providers/local_resolver.py +208 -0
- velune/providers/module.py +15 -0
- velune/providers/ollama_manager.py +193 -0
- velune/providers/registry.py +173 -0
- velune/providers/router.py +82 -0
- velune/py.typed +0 -0
- velune/repository/__init__.py +21 -0
- velune/repository/analyzer.py +123 -0
- velune/repository/cognition.py +172 -0
- velune/repository/grapher.py +182 -0
- velune/repository/indexer.py +229 -0
- velune/repository/module.py +15 -0
- velune/repository/parser.py +378 -0
- velune/repository/project_type.py +293 -0
- velune/repository/scanner.py +177 -0
- velune/repository/schemas.py +102 -0
- velune/repository/tracker.py +233 -0
- velune/retrieval/__init__.py +27 -0
- velune/retrieval/graph.py +117 -0
- velune/retrieval/hybrid.py +250 -0
- velune/retrieval/keyword.py +113 -0
- velune/retrieval/module.py +19 -0
- velune/retrieval/reranker.py +68 -0
- velune/retrieval/schemas.py +59 -0
- velune/retrieval/vector.py +163 -0
- velune/telemetry/__init__.py +7 -0
- velune/telemetry/cognition.py +260 -0
- velune/telemetry/token_tracker.py +133 -0
- velune/tools/__init__.py +41 -0
- velune/tools/base/registry.py +84 -0
- velune/tools/base/tool.py +63 -0
- velune/tools/code/navigate.py +107 -0
- velune/tools/code/search.py +112 -0
- velune/tools/filesystem/read.py +75 -0
- velune/tools/filesystem/search.py +123 -0
- velune/tools/filesystem/write.py +160 -0
- velune/tools/git/history.py +185 -0
- velune/tools/git/operations.py +132 -0
- velune/tools/git/state.py +134 -0
- velune/tools/module.py +65 -0
- velune/tools/terminal/execute.py +72 -0
- velune/tools/terminal/history.py +47 -0
- velune/tools/web/fetch.py +55 -0
- velune/tools/web/validator.py +96 -0
- velune_cli-1.0.0.dist-info/METADATA +497 -0
- velune_cli-1.0.0.dist-info/RECORD +229 -0
- velune_cli-1.0.0.dist-info/WHEEL +4 -0
- velune_cli-1.0.0.dist-info/entry_points.txt +2 -0
- 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
|
+
|