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,244 @@
1
+ """Doctor integration for telemetry and usage analytics."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime
6
+ from typing import Any
7
+
8
+ import structlog
9
+ from rich.console import Console
10
+ from rich.panel import Panel
11
+ from rich.table import Table
12
+
13
+ from velune.telemetry.usage_tracker import get_tracker
14
+
15
+ logger = structlog.get_logger()
16
+
17
+
18
+ def print_provider_health_report(console: Console | None = None) -> dict[str, Any]:
19
+ """Print provider health and capability manifest report.
20
+
21
+ Returns:
22
+ Dictionary with provider health data
23
+ """
24
+ if console is None:
25
+ console = Console()
26
+
27
+ try:
28
+ from velune.kernel.registry import get_container
29
+
30
+ container = get_container()
31
+ if not container.has("runtime.provider_health_monitor"):
32
+ return {"providers": []}
33
+ monitor = container.get("runtime.provider_health_monitor")
34
+ except (ImportError, AttributeError, KeyError):
35
+ return {"providers": []}
36
+
37
+ manifests = monitor.get_all_manifests()
38
+
39
+ # Print header
40
+ console.print(
41
+ Panel(
42
+ "[bold cyan]🏥 Provider Health[/bold cyan]",
43
+ expand=False,
44
+ )
45
+ )
46
+
47
+ if not manifests:
48
+ console.print("[dim]No provider manifests available[/dim]")
49
+ return {"providers": []}
50
+
51
+ # Create health table
52
+ table = Table(title=None, show_header=True)
53
+ table.add_column("Provider", style="cyan")
54
+ table.add_column("Status", style="green")
55
+ table.add_column("Latency", justify="right", style="yellow")
56
+ table.add_column("Models", justify="right", style="blue")
57
+ table.add_column("Rate Limit", style="magenta")
58
+
59
+ provider_data = []
60
+ for provider_id, manifest in sorted(manifests.items()):
61
+ from velune.core.types.provider import ProviderHealth
62
+
63
+ # Format status with icon
64
+ if manifest.health == ProviderHealth.HEALTHY:
65
+ status_str = "[green]✓ HEALTHY[/green]"
66
+ elif manifest.health == ProviderHealth.DEGRADED:
67
+ status_str = "[yellow]⚠ DEGRADED[/yellow]"
68
+ elif manifest.health == ProviderHealth.UNAVAILABLE:
69
+ status_str = "[red]✗ OFFLINE[/red]"
70
+ else:
71
+ status_str = "[dim]? UNKNOWN[/dim]"
72
+
73
+ # Format latency
74
+ if manifest.estimated_latency_ms > 0:
75
+ latency_str = f"{manifest.estimated_latency_ms}ms"
76
+ else:
77
+ latency_str = "—"
78
+
79
+ # Format model count
80
+ model_count = len(manifest.available_models)
81
+ models_str = str(model_count) if model_count > 0 else "—"
82
+
83
+ # Format rate limit
84
+ if manifest.rate_limit_remaining is not None:
85
+ if manifest.rate_limit_reset_at:
86
+ import time
87
+
88
+ reset_delta = int(manifest.rate_limit_reset_at - time.time())
89
+ reset_min = max(0, reset_delta // 60)
90
+ limit_str = f"{manifest.rate_limit_remaining} remaining (↻ {reset_min}m)"
91
+ else:
92
+ limit_str = f"{manifest.rate_limit_remaining} remaining"
93
+ else:
94
+ limit_str = "N/A (local)"
95
+
96
+ table.add_row(provider_id, status_str, latency_str, models_str, limit_str)
97
+ provider_data.append(
98
+ {
99
+ "provider": provider_id,
100
+ "health": str(manifest.health),
101
+ "latency_ms": manifest.estimated_latency_ms,
102
+ "model_count": model_count,
103
+ "rate_limit": manifest.rate_limit_remaining,
104
+ }
105
+ )
106
+
107
+ console.print(table)
108
+ return {"providers": provider_data}
109
+
110
+
111
+ def print_telemetry_report(console: Console | None = None) -> dict[str, Any]:
112
+ """Print telemetry and recent activity report for 'velune doctor'.
113
+
114
+ Returns:
115
+ Dictionary with report data for structured analysis
116
+ """
117
+ if console is None:
118
+ console = Console()
119
+
120
+ tracker = get_tracker()
121
+
122
+ # Get stats for last 7 days
123
+ stats = tracker.get_stats_last_n_days(days=7)
124
+
125
+ # Get recent sessions
126
+ recent_sessions = tracker.get_recent_sessions(days=7)
127
+
128
+ # Print header
129
+ console.print(
130
+ Panel(
131
+ "[bold cyan]📊 Telemetry & Usage (Last 7 Days)[/bold cyan]",
132
+ expand=False,
133
+ )
134
+ )
135
+
136
+ # Print summary stats
137
+ summary_lines = [
138
+ f"[green]Sessions:[/green] {stats['session_count']}",
139
+ f"[green]Total Tokens:[/green] {stats['total_tokens']:,}",
140
+ f"[green]Completions:[/green] {stats['completion_count']}",
141
+ ]
142
+
143
+ if stats["total_cost"]:
144
+ summary_lines.append(f"[green]Estimated Cost:[/green] ${stats['total_cost']:.2f}")
145
+
146
+ if stats["most_used_model"]:
147
+ summary_lines.append(
148
+ f"[green]Most Used Model:[/green] {stats['most_used_model']} "
149
+ f"({stats['most_used_model_tokens']:,} tokens)"
150
+ )
151
+
152
+ console.print("\n".join(summary_lines))
153
+
154
+ # Print recent sessions table if any
155
+ if recent_sessions:
156
+ console.print("\n[bold]Recent Sessions:[/bold]")
157
+
158
+ table = Table(title=None, show_header=True)
159
+ table.add_column("Session ID", style="cyan")
160
+ table.add_column("Start Time", style="green")
161
+ table.add_column("Tokens", justify="right", style="yellow")
162
+ table.add_column("Cost", justify="right", style="magenta")
163
+ table.add_column("Models", style="blue")
164
+
165
+ for session in recent_sessions[:10]: # Show last 10
166
+ # Parse start time
167
+ start_time = datetime.fromisoformat(session.start_time)
168
+ time_str = start_time.strftime("%Y-%m-%d %H:%M")
169
+
170
+ # Format models
171
+ models_str = ", ".join(f"{m}({t:,})" for m, t in session.model_breakdown.items())
172
+
173
+ # Format cost
174
+ cost_str = f"${session.total_cost:.2f}" if session.total_cost else "—"
175
+
176
+ table.add_row(
177
+ session.session_id[:8],
178
+ time_str,
179
+ str(session.total_tokens),
180
+ cost_str,
181
+ models_str,
182
+ )
183
+
184
+ console.print(table)
185
+
186
+ # Print average metrics
187
+ if stats["session_count"] > 0:
188
+ avg_tokens_per_session = stats["total_tokens"] // stats["session_count"]
189
+ avg_cost_per_session = (
190
+ stats["total_cost"] / stats["session_count"] if stats["total_cost"] else None
191
+ )
192
+
193
+ console.print("\n[bold]Averages per Session:[/bold]")
194
+ avg_lines = [f" Tokens: {avg_tokens_per_session:,}"]
195
+ if avg_cost_per_session:
196
+ avg_lines.append(f" Cost: ${avg_cost_per_session:.2f}")
197
+ console.print("\n".join(avg_lines))
198
+
199
+ # Print log location
200
+ from velune.telemetry.logging import get_log_directory
201
+
202
+ log_dir = get_log_directory()
203
+ console.print(f"\n[dim]Logs: {log_dir}[/dim]")
204
+
205
+ # Return structured data
206
+ return {
207
+ "telemetry": {
208
+ "sessions_7d": stats["session_count"],
209
+ "total_tokens_7d": stats["total_tokens"],
210
+ "total_cost_7d": stats["total_cost"],
211
+ "completions_7d": stats["completion_count"],
212
+ "most_used_model": stats["most_used_model"],
213
+ "avg_tokens_per_session": (
214
+ stats["total_tokens"] // stats["session_count"] if stats["session_count"] > 0 else 0
215
+ ),
216
+ },
217
+ "recent_sessions": [
218
+ {
219
+ "session_id": s.session_id,
220
+ "tokens": s.total_tokens,
221
+ "cost": s.total_cost,
222
+ "models": list(s.model_breakdown.keys()),
223
+ }
224
+ for s in recent_sessions[:10]
225
+ ],
226
+ }
227
+
228
+
229
+ def get_telemetry_status() -> dict[str, Any]:
230
+ """Get telemetry status for health checks.
231
+
232
+ Returns:
233
+ Dictionary with status and counts
234
+ """
235
+ tracker = get_tracker()
236
+ stats = tracker.get_stats_last_n_days(days=7)
237
+
238
+ return {
239
+ "healthy": True,
240
+ "sessions_tracked": stats["session_count"],
241
+ "tokens_tracked": stats["total_tokens"],
242
+ "estimated_cost": stats["total_cost"],
243
+ "database": str(tracker.db_path),
244
+ }
@@ -0,0 +1,286 @@
1
+ """Production-grade structured logging configuration using structlog.
2
+
3
+ Provides:
4
+ - JSON output to file (rotated daily, 7-day retention)
5
+ - Human-readable console output (unless --json flag)
6
+ - Automatic context binding (session_id, workspace_root, active_model)
7
+ - Log level from config or --debug flag
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+ import logging.handlers
14
+ import sys
15
+ from contextlib import contextmanager
16
+ from datetime import datetime, timedelta
17
+ from pathlib import Path
18
+ from typing import TYPE_CHECKING, Any
19
+
20
+ import structlog
21
+ from structlog.processors import (
22
+ CallsiteParameterAdder,
23
+ JSONRenderer,
24
+ TimeStamper,
25
+ add_log_level,
26
+ format_exc_info,
27
+ )
28
+ from structlog.stdlib import ProcessorFormatter, add_logger_name
29
+ from structlog.typing import Processor
30
+
31
+ if TYPE_CHECKING:
32
+ from velune.kernel.config import VeluneConfig
33
+
34
+ logger = structlog.get_logger()
35
+
36
+ # Global state for configure_logging
37
+ _configured = False
38
+ _log_dir: Path | None = None
39
+
40
+
41
+ def configure_logging(
42
+ config: VeluneConfig | None = None,
43
+ debug: bool = False,
44
+ json_output: bool = False,
45
+ ) -> None:
46
+ """Configure structlog with file and console output.
47
+
48
+ MUST be called once before any other initialization in kernel/entrypoint.py.
49
+
50
+ Args:
51
+ config: VeluneConfig with log_level setting
52
+ debug: If True, override to DEBUG level
53
+ json_output: If True, output JSON to console (default: human-readable)
54
+ """
55
+ global _configured, _log_dir
56
+
57
+ if _configured:
58
+ return
59
+
60
+ _configured = True
61
+
62
+ # Determine log level
63
+ log_level = "DEBUG" if debug else (config.log_level.upper() if config else "INFO")
64
+ numeric_level = getattr(logging, log_level, logging.INFO)
65
+
66
+ # Create log directory
67
+ _log_dir = Path.home() / ".velune" / "logs"
68
+ _log_dir.mkdir(parents=True, exist_ok=True)
69
+
70
+ # Shared processors for structure
71
+ shared_processors: list[Processor] = [
72
+ add_log_level,
73
+ add_logger_name,
74
+ TimeStamper(fmt="iso"),
75
+ CallsiteParameterAdder(
76
+ parameters=[CallsiteParameterAdder.FILENAME, CallsiteParameterAdder.LINENO]
77
+ ),
78
+ format_exc_info,
79
+ ]
80
+
81
+ # File handler: JSON output
82
+ file_handler = logging.handlers.RotatingFileHandler(
83
+ filename=_log_dir / f"velune-{datetime.now().strftime('%Y-%m-%d')}.log",
84
+ maxBytes=50 * 1024 * 1024, # 50 MB per file
85
+ backupCount=7, # Keep 7 days
86
+ )
87
+ file_handler.setLevel(numeric_level)
88
+ file_handler.setFormatter(
89
+ ProcessorFormatter(
90
+ processor=JSONRenderer(),
91
+ foreign_pre_chain=shared_processors,
92
+ )
93
+ )
94
+
95
+ # Console handler: Human-readable or JSON
96
+ console_handler = logging.StreamHandler(sys.stdout)
97
+ console_handler.setLevel(numeric_level)
98
+
99
+ if json_output:
100
+ console_processor = JSONRenderer()
101
+ else:
102
+ console_processor = _ConsoleRenderer(colors=_supports_color())
103
+
104
+ console_handler.setFormatter(
105
+ ProcessorFormatter(
106
+ processor=console_processor,
107
+ foreign_pre_chain=shared_processors,
108
+ )
109
+ )
110
+
111
+ # Configure root logger
112
+ root_logger = logging.getLogger()
113
+ root_logger.setLevel(numeric_level)
114
+ root_logger.handlers.clear()
115
+ root_logger.addHandler(file_handler)
116
+ root_logger.addHandler(console_handler)
117
+
118
+ # Configure structlog
119
+ structlog.configure(
120
+ processors=shared_processors
121
+ + [
122
+ structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
123
+ ],
124
+ context_class=dict,
125
+ logger_factory=structlog.stdlib.LoggerFactory(),
126
+ cache_logger_on_first_use=True,
127
+ )
128
+
129
+ logger.info(
130
+ "Logging configured",
131
+ log_level=log_level,
132
+ file=str(_log_dir / f"velune-{datetime.now().strftime('%Y-%m-%d')}.log"),
133
+ json_console=json_output,
134
+ )
135
+
136
+ # Schedule log cleanup (keep only 7 days)
137
+ _cleanup_old_logs()
138
+
139
+
140
+ def get_log_directory() -> Path:
141
+ """Get the configured log directory."""
142
+ if _log_dir is None:
143
+ return Path.home() / ".velune" / "logs"
144
+ return _log_dir
145
+
146
+
147
+ def _cleanup_old_logs(retention_days: int = 7) -> None:
148
+ """Remove log files older than retention_days."""
149
+ log_dir = get_log_directory()
150
+ if not log_dir.exists():
151
+ return
152
+
153
+ cutoff = datetime.now() - timedelta(days=retention_days)
154
+
155
+ for log_file in log_dir.glob("velune-*.log*"):
156
+ try:
157
+ # Parse date from filename: velune-2026-06-12.log
158
+ date_str = log_file.stem.replace("velune-", "").split(".")[0]
159
+ file_date = datetime.strptime(date_str, "%Y-%m-%d")
160
+
161
+ if file_date < cutoff:
162
+ log_file.unlink()
163
+ except (ValueError, OSError):
164
+ # Skip files we can't parse or delete
165
+ pass
166
+
167
+
168
+ class _ConsoleRenderer:
169
+ """Human-readable console output renderer."""
170
+
171
+ def __init__(self, colors: bool = True) -> None:
172
+ self.colors = colors
173
+
174
+ def __call__(self, logger, method_name: str, event_dict: dict[str, Any]) -> str:
175
+ """Render event dict to human-readable console format."""
176
+ level = event_dict.pop("level", "INFO").upper()
177
+ timestamp = event_dict.pop("timestamp", "")
178
+ logger_name = event_dict.pop("logger_name", "")
179
+ filename = event_dict.pop("filename", "")
180
+ lineno = event_dict.pop("lineno", "")
181
+
182
+ # Build prefix
183
+ prefix_parts = []
184
+ if timestamp:
185
+ prefix_parts.append(f"[{timestamp}]")
186
+ if self.colors:
187
+ level_colored = self._colorize_level(level)
188
+ prefix_parts.append(level_colored)
189
+ else:
190
+ prefix_parts.append(f"[{level}]")
191
+
192
+ prefix = " ".join(prefix_parts)
193
+
194
+ # Main message
195
+ message = event_dict.pop("event", "")
196
+
197
+ # Build context (remaining fields)
198
+ context_parts = []
199
+ for key, value in sorted(event_dict.items()):
200
+ if key.startswith("_"):
201
+ continue
202
+ context_parts.append(f"{key}={value!r}")
203
+
204
+ context = " " + " ".join(context_parts) if context_parts else ""
205
+
206
+ # Source location
207
+ location = f" ({logger_name}:{filename}:{lineno})" if filename and lineno else ""
208
+
209
+ return f"{prefix} {message}{context}{location}"
210
+
211
+ def _colorize_level(self, level: str) -> str:
212
+ """Add ANSI color codes to log level."""
213
+ colors = {
214
+ "DEBUG": "\033[36m[DEBUG]\033[0m", # Cyan
215
+ "INFO": "\033[32m[INFO]\033[0m", # Green
216
+ "WARNING": "\033[33m[WARNING]\033[0m", # Yellow
217
+ "ERROR": "\033[31m[ERROR]\033[0m", # Red
218
+ "CRITICAL": "\033[35m[CRITICAL]\033[0m", # Magenta
219
+ }
220
+ return colors.get(level, f"[{level}]")
221
+
222
+
223
+ def _supports_color() -> bool:
224
+ """Check if terminal supports color output."""
225
+ import os
226
+
227
+ # Check if stdout is a tty
228
+ if not hasattr(sys.stdout, "isatty"):
229
+ return False
230
+
231
+ if not sys.stdout.isatty():
232
+ return False
233
+
234
+ # Check environment variables
235
+ term = os.environ.get("TERM", "").lower()
236
+ no_color = os.environ.get("NO_COLOR", "").lower()
237
+
238
+ if no_color:
239
+ return False
240
+
241
+ # Common terminals that support color
242
+ if term in ("dumb", ""):
243
+ return False
244
+
245
+ return True
246
+
247
+
248
+ def bind_context(**kwargs) -> None:
249
+ """Bind key-value pairs to the structlog context.
250
+
251
+ These will appear in all subsequent log lines until unbind_context() is called.
252
+
253
+ Example:
254
+ bind_context(session_id="sess-123", workspace_root="/path/to/project")
255
+ """
256
+ structlog.contextvars.bind_contextvars(**kwargs)
257
+
258
+
259
+ def unbind_context(*keys: str) -> None:
260
+ """Unbind keys from the structlog context.
261
+
262
+ Example:
263
+ unbind_context("session_id", "workspace_root")
264
+ """
265
+ structlog.contextvars.unbind_contextvars(*keys)
266
+
267
+
268
+ def clear_context() -> None:
269
+ """Clear all context variables."""
270
+ structlog.contextvars.clear_contextvars()
271
+
272
+
273
+ @contextmanager
274
+ def context_scope(**kwargs) -> Any:
275
+ """Context manager for temporarily binding context variables.
276
+
277
+ Example:
278
+ with context_scope(span_id="span-456"):
279
+ # All logs here have span_id
280
+ logger.info("Processing")
281
+ """
282
+ structlog.contextvars.bind_contextvars(**kwargs)
283
+ try:
284
+ yield
285
+ finally:
286
+ structlog.contextvars.unbind_contextvars(*kwargs.keys())