velune-cli 0.9.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 (279) 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 +208 -0
  5. velune/cli/autocomplete.py +80 -0
  6. velune/cli/banner.py +60 -0
  7. velune/cli/commands/__init__.py +32 -0
  8. velune/cli/commands/ask.py +175 -0
  9. velune/cli/commands/base.py +16 -0
  10. velune/cli/commands/chat.py +228 -0
  11. velune/cli/commands/config.py +224 -0
  12. velune/cli/commands/daemon.py +88 -0
  13. velune/cli/commands/doctor.py +721 -0
  14. velune/cli/commands/init.py +170 -0
  15. velune/cli/commands/mcp.py +82 -0
  16. velune/cli/commands/memory.py +293 -0
  17. velune/cli/commands/models.py +683 -0
  18. velune/cli/commands/preflight.py +95 -0
  19. velune/cli/commands/run.py +270 -0
  20. velune/cli/commands/setup.py +184 -0
  21. velune/cli/commands/workspace.py +249 -0
  22. velune/cli/context.py +36 -0
  23. velune/cli/councilmodel_ui.py +199 -0
  24. velune/cli/display/council_view.py +254 -0
  25. velune/cli/display/memory_view.py +126 -0
  26. velune/cli/display/panels.py +35 -0
  27. velune/cli/display/progress.py +25 -0
  28. velune/cli/display/themes.py +25 -0
  29. velune/cli/main.py +15 -0
  30. velune/cli/model_selector.py +51 -0
  31. velune/cli/modes.py +86 -0
  32. velune/cli/pull_ui.py +123 -0
  33. velune/cli/registry.py +80 -0
  34. velune/cli/rendering/__init__.py +5 -0
  35. velune/cli/rendering/error_panel.py +79 -0
  36. velune/cli/rendering/markdown.py +63 -0
  37. velune/cli/repl.py +1855 -0
  38. velune/cli/session_manager.py +71 -0
  39. velune/cli/slash_commands.py +37 -0
  40. velune/cli/theme.py +8 -0
  41. velune/cognition/__init__.py +23 -0
  42. velune/cognition/agents/__init__.py +7 -0
  43. velune/cognition/agents/coder.py +209 -0
  44. velune/cognition/agents/planner.py +156 -0
  45. velune/cognition/agents/reviewer.py +195 -0
  46. velune/cognition/arbitrator.py +220 -0
  47. velune/cognition/architecture.py +415 -0
  48. velune/cognition/budget.py +65 -0
  49. velune/cognition/council/__init__.py +47 -0
  50. velune/cognition/council/base.py +217 -0
  51. velune/cognition/council/challenger.py +74 -0
  52. velune/cognition/council/coder.py +79 -0
  53. velune/cognition/council/critic_agent.py +43 -0
  54. velune/cognition/council/critic_configs.py +111 -0
  55. velune/cognition/council/critics.py +41 -0
  56. velune/cognition/council/debate.py +46 -0
  57. velune/cognition/council/factory.py +140 -0
  58. velune/cognition/council/messages.py +56 -0
  59. velune/cognition/council/planner.py +124 -0
  60. velune/cognition/council/reviewer.py +74 -0
  61. velune/cognition/council/synthesizer.py +67 -0
  62. velune/cognition/council/tiers.py +188 -0
  63. velune/cognition/council_orchestrator.py +282 -0
  64. velune/cognition/firewall.py +354 -0
  65. velune/cognition/module.py +46 -0
  66. velune/cognition/orchestrator.py +1205 -0
  67. velune/cognition/personality.py +238 -0
  68. velune/cognition/state.py +104 -0
  69. velune/cognition/style_resolver.py +64 -0
  70. velune/cognition/verification.py +205 -0
  71. velune/context/__init__.py +28 -0
  72. velune/context/assembler.py +240 -0
  73. velune/context/budget.py +97 -0
  74. velune/context/extractive.py +95 -0
  75. velune/context/prompt_adaptation.py +480 -0
  76. velune/context/sections.py +99 -0
  77. velune/context/token_counter.py +134 -0
  78. velune/context/utilization.py +33 -0
  79. velune/context/window.py +63 -0
  80. velune/core/__init__.py +89 -0
  81. velune/core/background.py +5 -0
  82. velune/core/config/__init__.py +37 -0
  83. velune/core/errors/__init__.py +90 -0
  84. velune/core/errors/catalog.py +188 -0
  85. velune/core/errors/execution.py +31 -0
  86. velune/core/errors/memory.py +25 -0
  87. velune/core/errors/orchestration.py +31 -0
  88. velune/core/errors/provider.py +37 -0
  89. velune/core/event_loop.py +35 -0
  90. velune/core/logging.py +83 -0
  91. velune/core/paths.py +165 -0
  92. velune/core/runtime.py +113 -0
  93. velune/core/startup_profiler.py +56 -0
  94. velune/core/task_registry.py +117 -0
  95. velune/core/trace.py +83 -0
  96. velune/core/types/__init__.py +48 -0
  97. velune/core/types/agent.py +53 -0
  98. velune/core/types/context.py +42 -0
  99. velune/core/types/inference.py +38 -0
  100. velune/core/types/memory.py +42 -0
  101. velune/core/types/model.py +70 -0
  102. velune/core/types/provider.py +62 -0
  103. velune/core/types/repository.py +38 -0
  104. velune/core/types/task.py +61 -0
  105. velune/core/types/workspace.py +28 -0
  106. velune/daemon/client.py +13 -0
  107. velune/daemon/server.py +127 -0
  108. velune/daemon/transport.py +179 -0
  109. velune/events.py +204 -0
  110. velune/execution/__init__.py +22 -0
  111. velune/execution/benchmarker.py +315 -0
  112. velune/execution/cancellation.py +53 -0
  113. velune/execution/checkpointer.py +130 -0
  114. velune/execution/command_spec.py +165 -0
  115. velune/execution/diff_preview.py +197 -0
  116. velune/execution/executor.py +181 -0
  117. velune/execution/module.py +18 -0
  118. velune/execution/multi_diff.py +67 -0
  119. velune/execution/path_guard.py +74 -0
  120. velune/execution/planner.py +91 -0
  121. velune/execution/rollback.py +89 -0
  122. velune/execution/sandbox.py +268 -0
  123. velune/execution/validator.py +115 -0
  124. velune/hardware/__init__.py +1 -0
  125. velune/hardware/detector.py +192 -0
  126. velune/kernel/__init__.py +55 -0
  127. velune/kernel/bootstrap.py +125 -0
  128. velune/kernel/config.py +426 -0
  129. velune/kernel/entrypoint.py +78 -0
  130. velune/kernel/health.py +54 -0
  131. velune/kernel/lifecycle.py +143 -0
  132. velune/kernel/module.py +17 -0
  133. velune/kernel/modules.py +23 -0
  134. velune/kernel/registry.py +96 -0
  135. velune/kernel/schemas.py +28 -0
  136. velune/main.py +9 -0
  137. velune/mcp/__init__.py +9 -0
  138. velune/mcp/client.py +115 -0
  139. velune/mcp/config.py +19 -0
  140. velune/mcp/server.py +624 -0
  141. velune/memory/__init__.py +32 -0
  142. velune/memory/compaction.py +506 -0
  143. velune/memory/embedding_pipeline.py +241 -0
  144. velune/memory/lifecycle.py +680 -0
  145. velune/memory/module.py +218 -0
  146. velune/memory/prioritizer.py +67 -0
  147. velune/memory/storage/episodic_schema.sql +53 -0
  148. velune/memory/storage/lancedb_store.py +282 -0
  149. velune/memory/storage/sqlite_manager.py +369 -0
  150. velune/memory/storage/sqlite_pool.py +149 -0
  151. velune/memory/tiers/episodic.py +588 -0
  152. velune/memory/tiers/graph.py +378 -0
  153. velune/memory/tiers/lineage.py +416 -0
  154. velune/memory/tiers/semantic.py +475 -0
  155. velune/memory/tiers/working.py +168 -0
  156. velune/memory/vitality.py +132 -0
  157. velune/models/__init__.py +15 -0
  158. velune/models/family.py +76 -0
  159. velune/models/module.py +20 -0
  160. velune/models/probes.py +192 -0
  161. velune/models/profile_cache.py +84 -0
  162. velune/models/profiler.py +108 -0
  163. velune/models/registry.py +251 -0
  164. velune/models/scorer.py +233 -0
  165. velune/models/specializations.py +205 -0
  166. velune/orchestration/__init__.py +19 -0
  167. velune/orchestration/engine.py +239 -0
  168. velune/orchestration/module.py +15 -0
  169. velune/orchestration/role_assignments.py +82 -0
  170. velune/orchestration/schemas.py +98 -0
  171. velune/plugins/__init__.py +20 -0
  172. velune/plugins/hooks.py +50 -0
  173. velune/plugins/loader.py +161 -0
  174. velune/plugins/registry.py +56 -0
  175. velune/plugins/schemas.py +21 -0
  176. velune/providers/__init__.py +23 -0
  177. velune/providers/adapters/anthropic.py +257 -0
  178. velune/providers/adapters/fireworks.py +115 -0
  179. velune/providers/adapters/google.py +234 -0
  180. velune/providers/adapters/groq.py +151 -0
  181. velune/providers/adapters/huggingface.py +210 -0
  182. velune/providers/adapters/llamacpp.py +208 -0
  183. velune/providers/adapters/lmstudio.py +175 -0
  184. velune/providers/adapters/ollama.py +233 -0
  185. velune/providers/adapters/openai.py +213 -0
  186. velune/providers/adapters/openrouter.py +81 -0
  187. velune/providers/adapters/together.py +134 -0
  188. velune/providers/adapters/xai.py +60 -0
  189. velune/providers/base.py +86 -0
  190. velune/providers/benchmarker.py +138 -0
  191. velune/providers/discovery/__init__.py +33 -0
  192. velune/providers/discovery/anthropic.py +79 -0
  193. velune/providers/discovery/benchmarks.py +44 -0
  194. velune/providers/discovery/classifier.py +69 -0
  195. velune/providers/discovery/fireworks.py +95 -0
  196. velune/providers/discovery/gguf.py +88 -0
  197. velune/providers/discovery/google.py +95 -0
  198. velune/providers/discovery/gpu.py +117 -0
  199. velune/providers/discovery/groq.py +21 -0
  200. velune/providers/discovery/huggingface.py +67 -0
  201. velune/providers/discovery/lmstudio.py +80 -0
  202. velune/providers/discovery/ollama.py +162 -0
  203. velune/providers/discovery/openai.py +96 -0
  204. velune/providers/discovery/openrouter.py +113 -0
  205. velune/providers/discovery/scanner.py +115 -0
  206. velune/providers/discovery/together.py +114 -0
  207. velune/providers/discovery/xai.py +57 -0
  208. velune/providers/health.py +67 -0
  209. velune/providers/health_monitor.py +169 -0
  210. velune/providers/keystore.py +142 -0
  211. velune/providers/local_paths.py +49 -0
  212. velune/providers/local_resolver.py +229 -0
  213. velune/providers/module.py +51 -0
  214. velune/providers/ollama_manager.py +193 -0
  215. velune/providers/registry.py +220 -0
  216. velune/providers/router.py +255 -0
  217. velune/providers/task_classifier.py +288 -0
  218. velune/py.typed +0 -0
  219. velune/repository/__init__.py +33 -0
  220. velune/repository/analyzer.py +127 -0
  221. velune/repository/ast_parser.py +822 -0
  222. velune/repository/blast_radius.py +298 -0
  223. velune/repository/boundary_classifier.py +295 -0
  224. velune/repository/cognition.py +316 -0
  225. velune/repository/grapher.py +179 -0
  226. velune/repository/import_graph.py +263 -0
  227. velune/repository/incremental_indexer.py +275 -0
  228. velune/repository/index_state.py +96 -0
  229. velune/repository/indexer.py +243 -0
  230. velune/repository/module.py +17 -0
  231. velune/repository/parser.py +474 -0
  232. velune/repository/project_type.py +300 -0
  233. velune/repository/rename_journal.py +287 -0
  234. velune/repository/scanner.py +193 -0
  235. velune/repository/schemas.py +102 -0
  236. velune/repository/symbol_registry.py +365 -0
  237. velune/repository/tracker.py +252 -0
  238. velune/retrieval/__init__.py +27 -0
  239. velune/retrieval/cache.py +110 -0
  240. velune/retrieval/fast_path.py +391 -0
  241. velune/retrieval/graph.py +124 -0
  242. velune/retrieval/hybrid.py +271 -0
  243. velune/retrieval/keyword.py +131 -0
  244. velune/retrieval/module.py +26 -0
  245. velune/retrieval/pipeline.py +303 -0
  246. velune/retrieval/reranker.py +102 -0
  247. velune/retrieval/schemas.py +59 -0
  248. velune/retrieval/slow_path.py +364 -0
  249. velune/retrieval/vector.py +203 -0
  250. velune/telemetry/__init__.py +59 -0
  251. velune/telemetry/cognition.py +267 -0
  252. velune/telemetry/cost_estimator.py +92 -0
  253. velune/telemetry/debug.py +304 -0
  254. velune/telemetry/doctor.py +244 -0
  255. velune/telemetry/logging.py +286 -0
  256. velune/telemetry/spans.py +277 -0
  257. velune/telemetry/token_tracker.py +140 -0
  258. velune/telemetry/usage_tracker.py +340 -0
  259. velune/tools/__init__.py +41 -0
  260. velune/tools/base/registry.py +87 -0
  261. velune/tools/base/tool.py +63 -0
  262. velune/tools/code/navigate.py +116 -0
  263. velune/tools/code/search.py +123 -0
  264. velune/tools/filesystem/read.py +75 -0
  265. velune/tools/filesystem/search.py +136 -0
  266. velune/tools/filesystem/write.py +163 -0
  267. velune/tools/git/history.py +177 -0
  268. velune/tools/git/operations.py +122 -0
  269. velune/tools/git/state.py +121 -0
  270. velune/tools/module.py +81 -0
  271. velune/tools/terminal/execute.py +72 -0
  272. velune/tools/terminal/history.py +47 -0
  273. velune/tools/web/fetch.py +55 -0
  274. velune/tools/web/validator.py +122 -0
  275. velune_cli-0.9.0.dist-info/METADATA +518 -0
  276. velune_cli-0.9.0.dist-info/RECORD +279 -0
  277. velune_cli-0.9.0.dist-info/WHEEL +4 -0
  278. velune_cli-0.9.0.dist-info/entry_points.txt +2 -0
  279. velune_cli-0.9.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,316 @@
1
+ """Repository cognition pipeline merging AST indices, Git history, and dependency graphs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ from pathlib import Path
8
+
9
+ from velune.repository.analyzer import CodebaseAnalyzer
10
+ from velune.repository.grapher import RepositoryGrapher
11
+ from velune.repository.indexer import RepositoryIndexer
12
+ from velune.repository.schemas import (
13
+ RepositoryEdge,
14
+ RepositorySnapshot,
15
+ )
16
+ from velune.repository.tracker import GitTracker
17
+
18
+ logger = logging.getLogger("velune.repository.cognition")
19
+
20
+ _STATE_FILENAME = "index_state.json"
21
+
22
+
23
+ class RepositoryCognitionService:
24
+ """The unified cognitive entrypoint mapping a workspace's AST structure, Git details, and dependencies."""
25
+
26
+ def __init__(self, root_path: Path) -> None:
27
+ self.root_path = root_path.resolve()
28
+ self.indexer = RepositoryIndexer(self.root_path)
29
+ self.grapher = RepositoryGrapher(self.root_path)
30
+ self.tracker = GitTracker(self.root_path)
31
+ self.analyzer = CodebaseAnalyzer(self.root_path)
32
+ self._state_path = self.root_path / ".velune" / _STATE_FILENAME
33
+ self._bg_index_task: asyncio.Task | None = None
34
+
35
+ # ------------------------------------------------------------------
36
+ # Lifecycle protocol (called by LifecycleCoordinator)
37
+ # ------------------------------------------------------------------
38
+
39
+ async def initialize(self) -> None:
40
+ """Start background incremental indexing — non-blocking for the REPL."""
41
+ from velune.repository.incremental_indexer import IncrementalIndexer
42
+
43
+ try:
44
+ from rich.console import Console
45
+
46
+ console = Console(stderr=True)
47
+ except Exception:
48
+ console = None # type: ignore[assignment]
49
+
50
+ def _print(msg: str) -> None:
51
+ if console:
52
+ console.print(msg)
53
+
54
+ _print("[dim]Checking repository...[/dim]")
55
+
56
+ inc = IncrementalIndexer(self.workspace_root, self._state_path)
57
+ try:
58
+ delta = await inc.compute_delta()
59
+ except Exception as exc:
60
+ logger.warning("Incremental delta check failed: %s", exc)
61
+ return
62
+
63
+ if delta.is_empty:
64
+ _print("[dim green]Repository index is up to date[/dim green]")
65
+ return
66
+
67
+ n = delta.total
68
+ _print(f"[dim]Indexing {n} changed file(s)...[/dim]")
69
+ self._bg_index_task = asyncio.create_task(self._background_apply(inc, delta, n))
70
+
71
+ async def shutdown(self) -> None:
72
+ """Cancel any pending background indexing task."""
73
+ if self._bg_index_task and not self._bg_index_task.done():
74
+ self._bg_index_task.cancel()
75
+ try:
76
+ await self._bg_index_task
77
+ except (asyncio.CancelledError, Exception):
78
+ pass
79
+
80
+ # ------------------------------------------------------------------
81
+ # Primary indexing API
82
+ # ------------------------------------------------------------------
83
+
84
+ @property
85
+ def workspace_root(self) -> Path:
86
+ return self.root_path
87
+
88
+ def index(self, force: bool = False) -> RepositorySnapshot:
89
+ """Index the repository, using a git-SHA fast path to skip unchanged repos.
90
+
91
+ On a cache hit (git HEAD SHA matches stored SHA AND working tree is clean),
92
+ the existing symbol + file cache is loaded and the full pipeline (grapher,
93
+ git metrics, architecture analysis) is run on top of it — skipping all file
94
+ I/O and SHA computation.
95
+
96
+ On a cache miss, the full ``RepositoryIndexer`` pipeline runs (incremental
97
+ at the file level via SHA256 comparison) and the ``IndexState`` is updated.
98
+ """
99
+ from velune.repository.incremental_indexer import IncrementalIndexer
100
+
101
+ inc = IncrementalIndexer(self.root_path, self._state_path)
102
+
103
+ if not force and inc._get_git_sha() == self._stored_commit_sha():
104
+ clean = inc._working_tree_is_clean()
105
+ if clean:
106
+ cached = self.get_snapshot()
107
+ if cached:
108
+ logger.debug("Fast path: reusing cached snapshot (git SHA matches).")
109
+ return self._run_pipeline(cached, update_state=False)
110
+
111
+ # Slow path: file-level incremental index
112
+ snapshot = self.indexer.index(force=force)
113
+
114
+ # Persist updated IndexState so the next session benefits from the fast path
115
+ self._persist_index_state(inc, snapshot)
116
+
117
+ return self._run_pipeline(snapshot, update_state=False)
118
+
119
+ # ------------------------------------------------------------------
120
+ # Read-only snapshot accessor (no indexing)
121
+ # ------------------------------------------------------------------
122
+
123
+ def get_snapshot(self) -> RepositorySnapshot | None:
124
+ """Return the last-computed snapshot from the on-disk cache, or None."""
125
+ import json
126
+
127
+ cache_path = self.indexer.cache_path
128
+ if not cache_path.exists():
129
+ return None
130
+
131
+ try:
132
+ with open(cache_path, encoding="utf-8") as f:
133
+ cache: dict = json.load(f)
134
+ except Exception:
135
+ return None
136
+
137
+ from velune.repository.schemas import (
138
+ RepositoryFile,
139
+ RepositoryLanguage,
140
+ RepositorySymbol,
141
+ )
142
+
143
+ files: list[RepositoryFile] = []
144
+ all_symbols: list[RepositorySymbol] = []
145
+
146
+ for rel_path, entry in cache.items():
147
+ try:
148
+ language = RepositoryLanguage(entry.get("language", "unknown"))
149
+ symbols = [RepositorySymbol(**s) for s in entry.get("symbols", [])]
150
+ file_rec = RepositoryFile(
151
+ path=rel_path,
152
+ language=language,
153
+ size_bytes=entry.get("size_bytes", 0),
154
+ sha256=entry.get("sha256", ""),
155
+ symbols=symbols,
156
+ metadata=entry.get("metadata", {}),
157
+ )
158
+ files.append(file_rec)
159
+ all_symbols.extend(symbols)
160
+ except Exception:
161
+ continue
162
+
163
+ return RepositorySnapshot(
164
+ root_path=str(self.root_path),
165
+ files=files,
166
+ symbols=all_symbols,
167
+ edges=[],
168
+ summary={
169
+ "total_files": len(files),
170
+ "total_symbols": len(all_symbols),
171
+ },
172
+ )
173
+
174
+ def traverse(self, node_id: str, depth: int = 2) -> list[str]:
175
+ """BFS traversal from *node_id* through the dependency graph."""
176
+ return self.grapher.traverse(node_id, depth)
177
+
178
+ # ------------------------------------------------------------------
179
+ # Internal helpers
180
+ # ------------------------------------------------------------------
181
+
182
+ def _run_pipeline(
183
+ self, snapshot: RepositorySnapshot, *, update_state: bool = False
184
+ ) -> RepositorySnapshot:
185
+ """Run grapher, git metrics, and architecture analysis on *snapshot*."""
186
+ # Reset grapher for this run (it is stateful and must be rebuilt each call)
187
+ self.grapher = RepositoryGrapher(self.root_path)
188
+
189
+ file_paths = [f.path for f in snapshot.files]
190
+ for f in snapshot.files:
191
+ self.grapher.add_file(f.path, f.language.value, f.size_bytes)
192
+ for sym in f.symbols:
193
+ self.grapher.add_symbol(sym)
194
+
195
+ self.grapher.resolve_import_dependencies(file_paths, snapshot.symbols)
196
+
197
+ edges: list[RepositoryEdge] = []
198
+ for src, tgt, _key, data in self.grapher.graph.edges(keys=True, data=True):
199
+ edges.append(
200
+ RepositoryEdge(
201
+ source=src,
202
+ target=tgt,
203
+ edge_type=data.get("edge_type", "depends"),
204
+ weight=data.get("weight", 1.0),
205
+ )
206
+ )
207
+ snapshot.edges = edges
208
+
209
+ # Git metrics (batched subprocesses)
210
+ branch = self.tracker.get_active_branch()
211
+ changes = self.tracker.get_uncommitted_changes()
212
+ recent_commits = self.tracker.get_recent_commits(limit=5)
213
+ all_volatility = self.tracker.get_all_file_volatility(days=90)
214
+ file_volatility: dict[str, int] = {}
215
+ for f in snapshot.files:
216
+ file_volatility[f.path] = all_volatility.get(f.path, 0) or all_volatility.get(
217
+ f.path.replace("/", "\\"), 0
218
+ )
219
+
220
+ # Architecture analysis (pure Python)
221
+ layers = self.analyzer.classify_architecture_layers(file_paths)
222
+ analyzer_edges = [(e.source, e.target) for e in edges]
223
+ violations = self.analyzer.detect_dependency_violations(layers, analyzer_edges)
224
+
225
+ code_files: dict[str, str] = {}
226
+ for f in snapshot.files:
227
+ if f.size_bytes < 100_000:
228
+ try:
229
+ code_files[f.path] = (self.root_path / f.path).read_text(
230
+ encoding="utf-8", errors="ignore"
231
+ )
232
+ except Exception:
233
+ pass
234
+ frameworks = self.analyzer.detect_framework_footprint(code_files)
235
+
236
+ snapshot.summary.update(
237
+ {
238
+ "git": {
239
+ "active_branch": branch,
240
+ "uncommitted_changes_count": len(changes),
241
+ "uncommitted_changes": changes[:10],
242
+ "recent_commits": recent_commits,
243
+ },
244
+ "architecture": {
245
+ "layers": {k: len(v) for k, v in layers.items()},
246
+ "violations_count": len(violations),
247
+ "violations": violations[:5],
248
+ "frameworks_detected": frameworks,
249
+ },
250
+ "metrics": {
251
+ "high_volatility_files": sorted(
252
+ file_volatility.items(), key=lambda x: x[1], reverse=True
253
+ )[:5],
254
+ },
255
+ }
256
+ )
257
+
258
+ return snapshot
259
+
260
+ def _stored_commit_sha(self) -> str | None:
261
+ """Return the git SHA stored in the last saved IndexState."""
262
+ from velune.repository.index_state import IndexState
263
+
264
+ state = IndexState.load(self._state_path)
265
+ return state.last_commit_sha if state else None
266
+
267
+ def _persist_index_state(self, inc: object, snapshot: RepositorySnapshot) -> None:
268
+ """Update IndexState on disk after a full index run."""
269
+ import time
270
+
271
+ from velune.repository.index_state import IndexedFile, IndexState
272
+
273
+ try:
274
+ git_sha = inc._get_git_sha() # type: ignore[attr-defined]
275
+ state = IndexState.load(self._state_path) or IndexState.empty(str(self.root_path))
276
+ now = time.time()
277
+ state.file_index = {
278
+ f.path: IndexedFile(
279
+ path=f.path,
280
+ content_hash=f.sha256,
281
+ language=f.language.value,
282
+ symbol_count=len(f.symbols),
283
+ indexed_at=now,
284
+ )
285
+ for f in snapshot.files
286
+ }
287
+ state.touch(git_sha)
288
+ state.workspace_root = str(self.root_path)
289
+ state.save(self._state_path)
290
+ except Exception as exc:
291
+ logger.debug("Could not persist IndexState: %s", exc)
292
+
293
+ async def _background_apply(
294
+ self,
295
+ inc: object,
296
+ delta: object,
297
+ n: int,
298
+ ) -> None:
299
+ """Apply a delta in the background and announce completion."""
300
+ try:
301
+ from rich.console import Console
302
+
303
+ console = Console(stderr=True)
304
+ except Exception:
305
+ console = None # type: ignore[assignment]
306
+
307
+ try:
308
+ await inc.apply_delta(delta) # type: ignore[attr-defined]
309
+ msg = f"[dim green]Repository index updated ({n} file(s))[/dim green]"
310
+ if console:
311
+ console.print(msg)
312
+ logger.info("Background incremental index complete: %d file(s) processed.", n)
313
+ except asyncio.CancelledError:
314
+ raise
315
+ except Exception as exc:
316
+ logger.warning("Background incremental indexing failed: %s", exc)
@@ -0,0 +1,179 @@
1
+ """Dependency and import grapher using networkx."""
2
+
3
+ import os
4
+ from pathlib import Path
5
+
6
+ import networkx as nx
7
+
8
+ from velune.repository.schemas import RepositoryEdge, RepositorySymbol, RepositorySymbolKind
9
+
10
+
11
+ class RepositoryGrapher:
12
+ """Builds and analyzes dependency graphs for repository files and symbols."""
13
+
14
+ def __init__(self, root_path: Path) -> None:
15
+ self.root_path = root_path.resolve()
16
+ self.graph = nx.MultiDiGraph()
17
+
18
+ def add_file(self, file_path: str, language: str, size_bytes: int) -> None:
19
+ """Adds a file node to the dependency graph."""
20
+ # Normalize paths relative to workspace
21
+ rel_path = self._to_rel_path(file_path)
22
+ self.graph.add_node(
23
+ rel_path, kind="file", language=language, size_bytes=size_bytes, type="file"
24
+ )
25
+
26
+ def add_symbol(self, symbol: RepositorySymbol) -> None:
27
+ """Adds a symbol node and binds it to its containing file."""
28
+ file_rel = self._to_rel_path(symbol.file_path)
29
+ sym_id = symbol.symbol_id or symbol.name
30
+
31
+ # Add symbol node
32
+ self.graph.add_node(
33
+ sym_id,
34
+ name=symbol.name,
35
+ qualified_name=symbol.qualified_name or symbol.name,
36
+ kind=symbol.kind.value,
37
+ file_path=file_rel,
38
+ line_start=symbol.line_start,
39
+ line_end=symbol.line_end,
40
+ type="symbol",
41
+ )
42
+
43
+ # Draw containing relationship
44
+ self.graph.add_edge(file_rel, sym_id, edge_type="contains", weight=1.0)
45
+
46
+ def add_edge(self, edge: RepositoryEdge) -> None:
47
+ """Adds a relationship edge between files or symbols."""
48
+ source_rel = self._to_rel_path(edge.source)
49
+ target_rel = self._to_rel_path(edge.target)
50
+ self.graph.add_edge(source_rel, target_rel, edge_type=edge.edge_type, weight=edge.weight)
51
+
52
+ def resolve_import_dependencies(
53
+ self, files: list[str], symbols: list[RepositorySymbol]
54
+ ) -> None:
55
+ """Resolves module imports to concrete files and draws file-to-file import edges."""
56
+ # Map module names and symbol names to files
57
+ file_by_module: dict[str, str] = {}
58
+ for f in files:
59
+ rel = self._to_rel_path(f)
60
+ # e.g., velune/kernel/bus.py -> velune.kernel.bus
61
+ mod_name = rel.replace(".py", "").replace("/", ".").replace("\\", ".")
62
+ file_by_module[mod_name] = rel
63
+
64
+ # Keep index.js -> index, index.ts -> index conversions
65
+ if rel.endswith(("__init__.py", "index.ts", "index.js")):
66
+ parent_mod = os.path.dirname(rel).replace("/", ".").replace("\\", ".")
67
+ file_by_module[parent_mod] = rel
68
+
69
+ # Map import symbols to files
70
+ for sym in symbols:
71
+ if sym.kind == RepositorySymbolKind.IMPORT:
72
+ source_file = self._to_rel_path(sym.file_path)
73
+ import_name = sym.name
74
+
75
+ # Check direct module name match
76
+ # e.g. from velune.kernel.bus import CognitiveBus -> import_name is "velune.kernel.bus"
77
+ matched_file = file_by_module.get(import_name)
78
+
79
+ # Fallback: check metadata module
80
+ if not matched_file and "module" in sym.metadata:
81
+ mod = sym.metadata["module"]
82
+ matched_file = file_by_module.get(mod)
83
+
84
+ # If still not found, check relative import resolution
85
+ if not matched_file and import_name.startswith("."):
86
+ source_dir = os.path.dirname(source_file)
87
+ # Resolve relative dot hierarchy
88
+ dots = len(import_name) - len(import_name.lstrip("."))
89
+ parts = source_dir.split(os.sep) if source_dir else []
90
+ if len(parts) >= dots - 1:
91
+ target_dir_parts = parts[: len(parts) - (dots - 1)]
92
+ sub_mod = import_name.lstrip(".")
93
+ target_mod = (
94
+ ".".join(target_dir_parts + [sub_mod]) if target_dir_parts else sub_mod
95
+ )
96
+ matched_file = file_by_module.get(target_mod)
97
+
98
+ if matched_file and source_file != matched_file:
99
+ self.graph.add_edge(source_file, matched_file, edge_type="imports", weight=1.0)
100
+
101
+ def traverse(self, node_id: str, depth: int = 2) -> list[str]:
102
+ """BFS traversal to discover connected file and symbol nodes up to specified depth."""
103
+ node_rel = self._to_rel_path(node_id)
104
+
105
+ # Determine starting nodes
106
+ start_nodes = []
107
+ if node_rel in self.graph:
108
+ start_nodes.append(node_rel)
109
+ elif node_id in self.graph:
110
+ start_nodes.append(node_id)
111
+ else:
112
+ # Search by name or qualified_name in node attributes
113
+ for n, data in self.graph.nodes(data=True):
114
+ if data.get("type") == "symbol":
115
+ if data.get("name") == node_id or data.get("qualified_name") == node_id:
116
+ start_nodes.append(n)
117
+
118
+ if not start_nodes:
119
+ return []
120
+
121
+ visited: set[str] = set(start_nodes)
122
+ queue = list(start_nodes)
123
+
124
+ for _ in range(depth):
125
+ next_queue = []
126
+ for node in queue:
127
+ # Add successors (outgoing links)
128
+ if node in self.graph:
129
+ for succ in self.graph.successors(node):
130
+ if succ not in visited:
131
+ visited.add(succ)
132
+ next_queue.append(succ)
133
+ # Add predecessors (incoming links)
134
+ for pred in self.graph.predecessors(node):
135
+ if pred not in visited:
136
+ visited.add(pred)
137
+ next_queue.append(pred)
138
+ queue = next_queue
139
+
140
+ return list(visited)
141
+
142
+ def get_dependencies(self, file_path: str) -> list[str]:
143
+ """Returns files imported by the given file."""
144
+ rel = self._to_rel_path(file_path)
145
+ if rel not in self.graph:
146
+ return []
147
+
148
+ deps = []
149
+ for _, target, data in self.graph.out_edges(rel, data=True):
150
+ if data.get("edge_type") == "imports":
151
+ deps.append(target)
152
+ return deps
153
+
154
+ def get_dependents(self, file_path: str) -> list[str]:
155
+ """Returns files that import the given file."""
156
+ rel = self._to_rel_path(file_path)
157
+ if rel not in self.graph:
158
+ return []
159
+
160
+ dependents = []
161
+ for source, _, data in self.graph.in_edges(rel, data=True):
162
+ if data.get("edge_type") == "imports":
163
+ dependents.append(source)
164
+ return dependents
165
+
166
+ def _to_rel_path(self, path_str: str) -> str:
167
+ """Helper to ensure paths are represented as uniform, workspace-relative strings."""
168
+ if not path_str or not (
169
+ path_str.startswith("/") or path_str.startswith("\\") or ":" in path_str
170
+ ):
171
+ # Already relative or a symbol name
172
+ return path_str.replace("\\", "/")
173
+
174
+ try:
175
+ p = Path(path_str).resolve()
176
+ rel = p.relative_to(self.root_path)
177
+ return str(rel).replace("\\", "/")
178
+ except (ValueError, RuntimeError):
179
+ return path_str.replace("\\", "/")