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,263 @@
1
+ """Import graph builder for Python, JavaScript, and TypeScript files.
2
+
3
+ Constructs a directed graph where an edge A → B means "A imports from B".
4
+ Computes structural metrics: fan_in (who imports this?), fan_out (what does this import?).
5
+
6
+ Phase 2a uses simple regex for JS/TS; full tree-sitter parsing in Phase 2b-1.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import ast
12
+ import logging
13
+ import re
14
+ from dataclasses import dataclass, field
15
+ from pathlib import Path
16
+
17
+ logger = logging.getLogger("velune.repository.import_graph")
18
+
19
+
20
+ @dataclass
21
+ class ImportMetrics:
22
+ """Metrics for a module/symbol in the import graph."""
23
+
24
+ module_path: str
25
+ fan_in: int = 0 # Count of modules that import this module
26
+ fan_out: int = 0 # Count of modules this module imports
27
+ is_imported_by_tests: bool = False
28
+ importers: set[str] = field(default_factory=set) # Modules that import this
29
+ imports: set[str] = field(default_factory=set) # Modules this imports
30
+
31
+
32
+ class ImportGraphBuilder:
33
+ """Builds an import dependency graph for a codebase.
34
+
35
+ Extracts import statements from Python, JavaScript, and TypeScript files,
36
+ then computes fan_in/fan_out metrics for each module.
37
+ """
38
+
39
+ def __init__(self) -> None:
40
+ """Initialize the import graph builder."""
41
+ self._graph: dict[str, ImportMetrics] = {}
42
+ self._file_extensions = {".py", ".js", ".ts", ".jsx", ".tsx"}
43
+
44
+ def build_from_directory(self, root_path: Path) -> dict[str, ImportMetrics]:
45
+ """Scan directory recursively and build the import graph.
46
+
47
+ Parameters
48
+ ----------
49
+ root_path:
50
+ Root directory to scan for Python/JS/TS files.
51
+
52
+ Returns
53
+ -------
54
+ dict[str, ImportMetrics]:
55
+ Graph mapping module paths to their metrics.
56
+ """
57
+ self._graph.clear()
58
+
59
+ # First pass: discover all files
60
+ file_paths = self._discover_files(root_path)
61
+ logger.info("Discovered %d source files", len(file_paths))
62
+
63
+ # Second pass: extract imports from each file
64
+ for file_path in file_paths:
65
+ self._extract_imports(file_path, root_path)
66
+
67
+ # Third pass: compute metrics
68
+ self._compute_metrics()
69
+
70
+ logger.info("Built import graph with %d modules", len(self._graph))
71
+ return self._graph
72
+
73
+ def _discover_files(self, root_path: Path) -> list[Path]:
74
+ """Recursively discover all Python/JS/TS files."""
75
+ files = []
76
+ try:
77
+ for path in root_path.rglob("*"):
78
+ if path.is_file() and path.suffix in self._file_extensions:
79
+ # Skip common exclusions
80
+ if any(
81
+ part in ("node_modules", ".git", "__pycache__", ".venv", "venv")
82
+ for part in path.parts
83
+ ):
84
+ continue
85
+ files.append(path)
86
+ except Exception as exc:
87
+ logger.warning("Error discovering files in %s: %s", root_path, exc)
88
+ return sorted(files)
89
+
90
+ def _extract_imports(self, file_path: Path, root_path: Path) -> None:
91
+ """Extract import statements from a single file."""
92
+ try:
93
+ relative_path = file_path.relative_to(root_path).as_posix()
94
+
95
+ # Ensure module is in graph
96
+ if relative_path not in self._graph:
97
+ self._graph[relative_path] = ImportMetrics(module_path=relative_path)
98
+
99
+ if file_path.suffix == ".py":
100
+ self._extract_python_imports(file_path, relative_path)
101
+ elif file_path.suffix in {".js", ".ts", ".jsx", ".tsx"}:
102
+ self._extract_js_imports(file_path, relative_path)
103
+
104
+ except Exception as exc:
105
+ logger.debug("Failed to extract imports from %s: %s", file_path, exc)
106
+
107
+ def _extract_python_imports(self, file_path: Path, relative_path: str) -> None:
108
+ """Extract imports from a Python file using ast.parse()."""
109
+ try:
110
+ content = file_path.read_text(encoding="utf-8", errors="ignore")
111
+ tree = ast.parse(content)
112
+
113
+ for node in ast.walk(tree):
114
+ if isinstance(node, ast.Import):
115
+ for alias in node.names:
116
+ module_name = alias.name.split(".")[0]
117
+ self._add_import_edge(relative_path, module_name)
118
+
119
+ elif isinstance(node, ast.ImportFrom):
120
+ if node.module:
121
+ module_name = node.module.split(".")[0]
122
+ self._add_import_edge(relative_path, module_name)
123
+
124
+ except SyntaxError:
125
+ logger.debug("Syntax error parsing %s", file_path)
126
+ except Exception as exc:
127
+ logger.debug("Error extracting imports from %s: %s", file_path, exc)
128
+
129
+ def _extract_js_imports(self, file_path: Path, relative_path: str) -> None:
130
+ """Extract imports from JS/TS files using regex (Phase 2a).
131
+
132
+ Phase 2b will use full tree-sitter parsing for better accuracy.
133
+ """
134
+ try:
135
+ content = file_path.read_text(encoding="utf-8", errors="ignore")
136
+
137
+ # ES6 import: import ... from 'module' or "module"
138
+ es6_pattern = r"import\s+(?:{[^}]*}|[^'\"]*)\s+from\s+['\"]([^'\"]+)['\"]"
139
+ for match in re.finditer(es6_pattern, content):
140
+ module = match.group(1)
141
+ # Normalize: ./foo → foo, ../foo → foo
142
+ module = module.lstrip("./").split("/")[0]
143
+ if module and not module.startswith("."):
144
+ self._add_import_edge(relative_path, module)
145
+
146
+ # CommonJS require: require('module') or require("module")
147
+ require_pattern = r"require\s*\(\s*['\"]([^'\"]+)['\"]\s*\)"
148
+ for match in re.finditer(require_pattern, content):
149
+ module = match.group(1)
150
+ module = module.lstrip("./").split("/")[0]
151
+ if module and not module.startswith("."):
152
+ self._add_import_edge(relative_path, module)
153
+
154
+ except Exception as exc:
155
+ logger.debug("Error extracting imports from %s: %s", file_path, exc)
156
+
157
+ def _add_import_edge(self, from_module: str, to_module: str) -> None:
158
+ """Add an import edge A → B (A imports from B)."""
159
+ if from_module == to_module:
160
+ return # Skip self-imports
161
+
162
+ # Ensure both modules are in graph
163
+ if from_module not in self._graph:
164
+ self._graph[from_module] = ImportMetrics(module_path=from_module)
165
+ if to_module not in self._graph:
166
+ self._graph[to_module] = ImportMetrics(module_path=to_module)
167
+
168
+ # Add edge
169
+ self._graph[from_module].imports.add(to_module)
170
+ self._graph[to_module].importers.add(from_module)
171
+
172
+ def _compute_metrics(self) -> None:
173
+ """Compute fan_in, fan_out, and test coverage metrics."""
174
+ for metrics in self._graph.values():
175
+ metrics.fan_in = len(metrics.importers)
176
+ metrics.fan_out = len(metrics.imports)
177
+
178
+ # Check if imported by any test file
179
+ metrics.is_imported_by_tests = any(
180
+ "test_" in importer or ".test." in importer for importer in metrics.importers
181
+ )
182
+
183
+ def get_metrics(self, module_path: str) -> ImportMetrics | None:
184
+ """Get metrics for a specific module.
185
+
186
+ Parameters
187
+ ----------
188
+ module_path:
189
+ The relative module path.
190
+
191
+ Returns
192
+ -------
193
+ ImportMetrics | None:
194
+ Metrics if module exists, None otherwise.
195
+ """
196
+ return self._graph.get(module_path)
197
+
198
+ def get_all_metrics(self) -> dict[str, ImportMetrics]:
199
+ """Return the complete import graph."""
200
+ return self._graph.copy()
201
+
202
+ def get_importers(self, module_path: str) -> set[str]:
203
+ """Get all modules that import the given module.
204
+
205
+ Parameters
206
+ ----------
207
+ module_path:
208
+ The module to query.
209
+
210
+ Returns
211
+ -------
212
+ set[str]:
213
+ Modules that import the given module.
214
+ """
215
+ metrics = self._graph.get(module_path)
216
+ return metrics.importers if metrics else set()
217
+
218
+ def get_imports(self, module_path: str) -> set[str]:
219
+ """Get all modules imported by the given module.
220
+
221
+ Parameters
222
+ ----------
223
+ module_path:
224
+ The module to query.
225
+
226
+ Returns
227
+ -------
228
+ set[str]:
229
+ Modules imported by the given module.
230
+ """
231
+ metrics = self._graph.get(module_path)
232
+ return metrics.imports if metrics else set()
233
+
234
+ def get_transitive_dependents(self, module_path: str, max_depth: int = 5) -> set[str]:
235
+ """Get all modules that depend on this module (transitively).
236
+
237
+ Parameters
238
+ ----------
239
+ module_path:
240
+ The module to query.
241
+ max_depth:
242
+ Maximum traversal depth to avoid cycles.
243
+
244
+ Returns
245
+ -------
246
+ set[str]:
247
+ All modules that transitively depend on this module.
248
+ """
249
+ dependents = set()
250
+ queue = [(module_path, 0)]
251
+
252
+ while queue:
253
+ current, depth = queue.pop(0)
254
+ if depth > max_depth:
255
+ continue
256
+
257
+ importers = self.get_importers(current)
258
+ for importer in importers:
259
+ if importer not in dependents:
260
+ dependents.add(importer)
261
+ queue.append((importer, depth + 1))
262
+
263
+ return dependents
@@ -0,0 +1,275 @@
1
+ """Incremental repository indexer that skips unchanged files between sessions.
2
+
3
+ Algorithm
4
+ ---------
5
+ 1. Load the stored ``IndexState`` (or treat as empty on first run).
6
+ 2. Fetch the current git HEAD SHA.
7
+ 3. **Fast path**: if HEAD SHA equals the stored SHA *and* the working tree is
8
+ clean (no uncommitted changes), return an empty ``IndexDelta`` immediately —
9
+ no file I/O required.
10
+ 4. **Slow path**: walk workspace source files, compute SHA-256 for each, compare
11
+ to the stored hashes, and build the delta (added / updated / removed).
12
+ 5. ``apply_delta`` parses only the files in the delta, updates ``IndexState``,
13
+ and saves it to disk.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import asyncio
19
+ import hashlib
20
+ import logging
21
+ import subprocess
22
+ import time
23
+ from dataclasses import dataclass, field
24
+ from pathlib import Path
25
+
26
+ from velune.repository.index_state import IndexedFile, IndexState
27
+
28
+ logger = logging.getLogger("velune.repository.incremental_indexer")
29
+
30
+ # Directories always excluded from the walk (on top of .veluneignore)
31
+ _ALWAYS_SKIP = frozenset(
32
+ {
33
+ ".git",
34
+ "__pycache__",
35
+ "node_modules",
36
+ ".venv",
37
+ "venv",
38
+ "dist",
39
+ "build",
40
+ ".velune",
41
+ ".mypy_cache",
42
+ ".ruff_cache",
43
+ ".pytest_cache",
44
+ "*.egg-info",
45
+ }
46
+ )
47
+
48
+ _CODE_EXTENSIONS = frozenset(
49
+ {
50
+ ".py",
51
+ ".js",
52
+ ".ts",
53
+ ".jsx",
54
+ ".tsx",
55
+ ".go",
56
+ ".rs",
57
+ ".java",
58
+ ".c",
59
+ ".cpp",
60
+ ".h",
61
+ ".cs",
62
+ ".php",
63
+ ".rb",
64
+ ".swift",
65
+ ".kt",
66
+ }
67
+ )
68
+
69
+
70
+ @dataclass
71
+ class IndexDelta:
72
+ """Describes which files need to be added, re-parsed, or removed."""
73
+
74
+ to_add: list[str] = field(default_factory=list) # new files not in stored state
75
+ to_update: list[str] = field(default_factory=list) # files whose hash changed
76
+ to_remove: list[str] = field(default_factory=list) # files deleted from disk
77
+
78
+ @property
79
+ def is_empty(self) -> bool:
80
+ return not self.to_add and not self.to_update and not self.to_remove
81
+
82
+ @property
83
+ def total(self) -> int:
84
+ return len(self.to_add) + len(self.to_update) + len(self.to_remove)
85
+
86
+
87
+ class IncrementalIndexer:
88
+ """Computes and applies file-level deltas to keep ``IndexState`` current."""
89
+
90
+ def __init__(self, workspace_root: Path, state_path: Path) -> None:
91
+ self.workspace_root = workspace_root.resolve()
92
+ self.state_path = state_path
93
+
94
+ # ------------------------------------------------------------------
95
+ # Public async API
96
+ # ------------------------------------------------------------------
97
+
98
+ async def compute_delta(self) -> IndexDelta:
99
+ """Compare disk state to the stored ``IndexState`` and return the diff.
100
+
101
+ The git HEAD SHA is checked first. If it matches the stored SHA *and*
102
+ the working tree has no uncommitted changes, an empty delta is returned
103
+ immediately — no file reads required.
104
+ """
105
+ state = IndexState.load(self.state_path)
106
+ git_sha = await asyncio.to_thread(self._get_git_sha)
107
+
108
+ # --- Fast path ---
109
+ if (
110
+ state is not None
111
+ and state.last_commit_sha is not None
112
+ and state.last_commit_sha == git_sha
113
+ ):
114
+ clean = await asyncio.to_thread(self._working_tree_is_clean)
115
+ if clean:
116
+ logger.debug("Fast path: git SHA matches and working tree is clean.")
117
+ return IndexDelta()
118
+
119
+ # --- Slow path: walk files and compare hashes ---
120
+ return await asyncio.to_thread(self._compute_file_delta, state)
121
+
122
+ async def apply_delta(self, delta: IndexDelta) -> IndexState:
123
+ """Parse only the files in *delta*, update stored ``IndexState``, save to disk.
124
+
125
+ Files in ``delta.to_remove`` are dropped from the state.
126
+ Files in ``delta.to_add`` and ``delta.to_update`` are hashed and parsed.
127
+ """
128
+ state = IndexState.load(self.state_path) or IndexState.empty(str(self.workspace_root))
129
+
130
+ # Remove deleted files
131
+ for rel_path in delta.to_remove:
132
+ state.remove_file(rel_path)
133
+ logger.debug("Removed from index: %s", rel_path)
134
+
135
+ # Parse added and modified files
136
+ now = time.time()
137
+ for rel_path in delta.to_add + delta.to_update:
138
+ full_path = self.workspace_root / rel_path
139
+ if not full_path.exists():
140
+ continue
141
+ try:
142
+ content = full_path.read_text(encoding="utf-8", errors="ignore")
143
+ sha = self._hash_file(full_path)
144
+ symbols, language = await asyncio.to_thread(self._parse_file, full_path, content)
145
+ state.update_file(
146
+ IndexedFile(
147
+ path=rel_path,
148
+ content_hash=sha,
149
+ language=language,
150
+ symbol_count=len(symbols),
151
+ indexed_at=now,
152
+ )
153
+ )
154
+ logger.debug("Indexed: %s (%d symbols)", rel_path, len(symbols))
155
+ except Exception as exc:
156
+ logger.debug("Skipped %s during apply_delta: %s", rel_path, exc)
157
+
158
+ # Update metadata and persist
159
+ git_sha = await asyncio.to_thread(self._get_git_sha)
160
+ state.touch(git_sha)
161
+ state.workspace_root = str(self.workspace_root)
162
+ state.save(self.state_path)
163
+ return state
164
+
165
+ # ------------------------------------------------------------------
166
+ # Synchronous helpers (run in thread pool)
167
+ # ------------------------------------------------------------------
168
+
169
+ def _get_git_sha(self) -> str | None:
170
+ """Return the current git HEAD SHA, or None if git is unavailable."""
171
+ try:
172
+ result = subprocess.run(
173
+ ["git", "rev-parse", "HEAD"],
174
+ capture_output=True,
175
+ text=True,
176
+ cwd=str(self.workspace_root),
177
+ timeout=5,
178
+ )
179
+ if result.returncode == 0:
180
+ return result.stdout.strip()
181
+ except Exception:
182
+ pass
183
+ return None
184
+
185
+ def _working_tree_is_clean(self) -> bool:
186
+ """Return True when there are no staged or unstaged modifications."""
187
+ try:
188
+ result = subprocess.run(
189
+ ["git", "diff", "HEAD", "--name-only"],
190
+ capture_output=True,
191
+ text=True,
192
+ cwd=str(self.workspace_root),
193
+ timeout=5,
194
+ )
195
+ if result.returncode == 0:
196
+ return result.stdout.strip() == ""
197
+ except Exception:
198
+ pass
199
+ # If git is unavailable, assume dirty so we do the file scan
200
+ return False
201
+
202
+ def _compute_file_delta(self, state: IndexState | None) -> IndexDelta:
203
+ """Walk workspace files, compare hashes, and build the delta."""
204
+ stored = state.file_index if state else {}
205
+
206
+ # Discover current source files (reuse scanner for .veluneignore support)
207
+ try:
208
+ from velune.repository.scanner import FilesystemScanner
209
+
210
+ scanner = FilesystemScanner(self.workspace_root)
211
+ current_paths = scanner.scan_code_files()
212
+ except Exception:
213
+ current_paths = list(self._fallback_scan())
214
+
215
+ current: dict[str, Path] = {}
216
+ for p in current_paths:
217
+ try:
218
+ rel = str(p.relative_to(self.workspace_root)).replace("\\", "/")
219
+ current[rel] = p
220
+ except ValueError:
221
+ continue
222
+
223
+ to_add: list[str] = []
224
+ to_update: list[str] = []
225
+
226
+ for rel_path, abs_path in current.items():
227
+ try:
228
+ sha = self._hash_file(abs_path)
229
+ except Exception:
230
+ continue
231
+
232
+ stored_entry = stored.get(rel_path)
233
+ if stored_entry is None:
234
+ to_add.append(rel_path)
235
+ elif stored_entry.content_hash != sha:
236
+ to_update.append(rel_path)
237
+
238
+ to_remove = [p for p in stored if p not in current]
239
+
240
+ return IndexDelta(to_add=to_add, to_update=to_update, to_remove=to_remove)
241
+
242
+ def _fallback_scan(self) -> list[Path]:
243
+ """Minimal walk used when FilesystemScanner is unavailable."""
244
+ results: list[Path] = []
245
+ for path in self.workspace_root.rglob("*"):
246
+ if any(part in _ALWAYS_SKIP for part in path.parts):
247
+ continue
248
+ if path.is_file() and path.suffix.lower() in _CODE_EXTENSIONS:
249
+ results.append(path)
250
+ return results
251
+
252
+ def _parse_file(self, full_path: Path, content: str) -> tuple[list, str]:
253
+ """Parse *full_path* and return (symbols, language_str)."""
254
+ try:
255
+ from velune.repository.parser import ASTParser
256
+
257
+ parser = ASTParser()
258
+ symbols, _ = parser.parse(full_path, content)
259
+ lang = parser._detect_language(full_path)
260
+ return symbols, lang.value
261
+ except Exception:
262
+ return [], "unknown"
263
+
264
+ @staticmethod
265
+ def _hash_file(path: Path) -> str:
266
+ """SHA-256 of a file's raw bytes, read in 8-KiB chunks."""
267
+ sha = hashlib.sha256()
268
+ with open(path, "rb") as f:
269
+ for chunk in iter(lambda: f.read(8192), b""):
270
+ sha.update(chunk)
271
+ return sha.hexdigest()
272
+
273
+ @staticmethod
274
+ def _hash_content(data: bytes) -> str:
275
+ return hashlib.sha256(data).hexdigest()
@@ -0,0 +1,96 @@
1
+ """Persistent index state for incremental repository indexing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import time
8
+ from dataclasses import asdict, dataclass, field
9
+ from pathlib import Path
10
+
11
+ logger = logging.getLogger("velune.repository.index_state")
12
+
13
+
14
+ @dataclass
15
+ class IndexedFile:
16
+ """Per-file metadata stored between indexing sessions."""
17
+
18
+ path: str # Relative to workspace root (forward-slash)
19
+ content_hash: str # SHA-256 of file contents at index time
20
+ language: str # Detected language (e.g. "python")
21
+ symbol_count: int # Number of parsed symbols (0 if parsing failed)
22
+ indexed_at: float # Unix timestamp when this entry was written
23
+
24
+
25
+ @dataclass
26
+ class IndexState:
27
+ """Full workspace index state persisted between Velune sessions.
28
+
29
+ Saved as JSON at ``.velune/index_state.json``. The ``last_commit_sha``
30
+ is used for the primary fast-path check: if git HEAD has not moved since
31
+ the last index run, file scanning can be skipped entirely.
32
+ """
33
+
34
+ workspace_root: str
35
+ last_commit_sha: str | None # git HEAD SHA at last full index; None if no git
36
+ last_indexed_at: float # Unix timestamp of the last completed index
37
+ file_index: dict[str, IndexedFile] = field(default_factory=dict) # rel_path → IndexedFile
38
+
39
+ # ------------------------------------------------------------------
40
+ # Persistence helpers
41
+ # ------------------------------------------------------------------
42
+
43
+ def save(self, path: Path) -> None:
44
+ """Serialize this state to *path* as JSON (creates parent dirs as needed)."""
45
+ path.parent.mkdir(parents=True, exist_ok=True)
46
+ data = {
47
+ "workspace_root": self.workspace_root,
48
+ "last_commit_sha": self.last_commit_sha,
49
+ "last_indexed_at": self.last_indexed_at,
50
+ "file_index": {k: asdict(v) for k, v in self.file_index.items()},
51
+ }
52
+ try:
53
+ with open(path, "w", encoding="utf-8") as f:
54
+ json.dump(data, f, indent=2)
55
+ except Exception as exc:
56
+ logger.warning("Could not save index state to %s: %s", path, exc)
57
+
58
+ @classmethod
59
+ def load(cls, path: Path) -> IndexState | None:
60
+ """Deserialize from *path*. Returns ``None`` if the file is missing or corrupt."""
61
+ if not path.exists():
62
+ return None
63
+ try:
64
+ with open(path, encoding="utf-8") as f:
65
+ data = json.load(f)
66
+ file_index = {k: IndexedFile(**v) for k, v in data.get("file_index", {}).items()}
67
+ return cls(
68
+ workspace_root=data["workspace_root"],
69
+ last_commit_sha=data.get("last_commit_sha"),
70
+ last_indexed_at=float(data.get("last_indexed_at", 0.0)),
71
+ file_index=file_index,
72
+ )
73
+ except Exception as exc:
74
+ logger.warning("Could not load index state from %s: %s", path, exc)
75
+ return None
76
+
77
+ @classmethod
78
+ def empty(cls, workspace_root: str) -> IndexState:
79
+ """Return a blank state (first-run placeholder)."""
80
+ return cls(
81
+ workspace_root=workspace_root,
82
+ last_commit_sha=None,
83
+ last_indexed_at=0.0,
84
+ file_index={},
85
+ )
86
+
87
+ def update_file(self, indexed_file: IndexedFile) -> None:
88
+ self.file_index[indexed_file.path] = indexed_file
89
+
90
+ def remove_file(self, path: str) -> None:
91
+ self.file_index.pop(path, None)
92
+
93
+ def touch(self, commit_sha: str | None) -> None:
94
+ """Mark the state as freshly indexed at the current time."""
95
+ self.last_commit_sha = commit_sha
96
+ self.last_indexed_at = time.time()