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,680 @@
1
+ """Memory Subsystem Lifecycle and Coordinator.
2
+
3
+ Manages clean connections startup, shutdown flushes, and orchestrates
4
+ transient to persistent memory ingestion.
5
+
6
+ Phase 1 repairs:
7
+ * ``shutdown()`` now flushes any live working-memory turns into the
8
+ episodic SQLite tier before terminating. This ensures turn history
9
+ survives process exit and is not silently discarded.
10
+ * ``get_recent_context(session_id, limit)`` exposes the minimal episodic
11
+ read path so orchestrators can hydrate context from previous sessions
12
+ without importing the episodic tier directly.
13
+
14
+ Phase 2a: Enhanced MemoryLifecycleManager with:
15
+ * Multi-tier retrieval: working → episodic (LIKE) → semantic (ANN) → merged
16
+ * Vitality-based filtering: LIVE/ZOMBIE/ARCHIVED classification
17
+ * Lineage warnings: architectural decisions and failed experiments
18
+ * Health reporting: turn counts, queue depth, store size
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import asyncio
24
+ import logging
25
+ import time
26
+ from dataclasses import dataclass
27
+ from typing import Any
28
+
29
+ from pydantic import BaseModel, Field
30
+
31
+ logger = logging.getLogger("velune.memory.lifecycle")
32
+
33
+
34
+ class MemoryArtifact:
35
+ """Represents a discrete memory chunk captured during run finalization."""
36
+
37
+ def __init__(
38
+ self,
39
+ id: str,
40
+ memory_type: str,
41
+ content: str,
42
+ importance: float,
43
+ metadata: dict[str, Any],
44
+ ) -> None:
45
+ self.id = id
46
+ self.memory_type = memory_type
47
+ self.content = content
48
+ self.importance = importance
49
+ self.metadata = metadata
50
+
51
+
52
+ # ─────────────────────────────────────────────────────────────────────────────
53
+ # Phase 2a: Multi-tier retrieval and vitality-based filtering
54
+ # ─────────────────────────────────────────────────────────────────────────────
55
+
56
+
57
+ class RetrievedResult(BaseModel):
58
+ """A single result from multi-tier memory retrieval."""
59
+
60
+ content: str
61
+ source_type: str # "working" | "episodic" | "semantic"
62
+ relevance_score: float = 0.0 # 0-1, higher is more relevant
63
+ trust_score: float = 1.0 # 0-1, influenced by vitality
64
+ vitality: str = "live" # "live" | "zombie" | "archived"
65
+ session_id: str = ""
66
+ age_seconds: float = 0.0
67
+ attribution: str = "" # e.g., "3 days ago" or "current session"
68
+
69
+
70
+ class RetrievedContext(BaseModel):
71
+ """Aggregated context returned from multi-tier retrieval."""
72
+
73
+ results: list[RetrievedResult] = Field(default_factory=list)
74
+ total_tokens: int = 0
75
+ query: str = ""
76
+ fallback_to_zombie: bool = False # Whether ZOMBIE tier was accessed
77
+ workspace_root: str = ""
78
+
79
+
80
+ class Decision(BaseModel):
81
+ """An architectural decision from the lineage tier."""
82
+
83
+ id: str
84
+ target_subsystem: str
85
+ rationale: str
86
+ architectural_impact: float = 0.0
87
+ consequences: str | None = None
88
+ timestamp: float = 0.0
89
+
90
+
91
+ class Failure(BaseModel):
92
+ """A failed experiment from the lineage tier."""
93
+
94
+ id: int
95
+ target_subsystem: str
96
+ error_type: str
97
+ error_message: str
98
+ timestamp: float = 0.0
99
+
100
+
101
+ @dataclass
102
+ class MemoryHealth:
103
+ """Health metrics for the memory subsystem."""
104
+
105
+ working_memory_turns: int = 0
106
+ episodic_sessions: int = 0
107
+ semantic_indexed_count: int = 0
108
+ embedding_queue_depth: int = 0
109
+ lancedb_size_mb: float = 0.0
110
+ timestamp: float = 0.0
111
+
112
+ def to_dict(self) -> dict[str, Any]:
113
+ """Convert to dictionary for display."""
114
+ return {
115
+ "working_memory_turns": self.working_memory_turns,
116
+ "episodic_sessions": self.episodic_sessions,
117
+ "semantic_indexed_count": self.semantic_indexed_count,
118
+ "embedding_queue_depth": self.embedding_queue_depth,
119
+ "lancedb_size_mb": self.lancedb_size_mb,
120
+ }
121
+
122
+
123
+ class MemoryLifecycleCoordinator:
124
+ """Orchestrates memory subsystem boot protocols and handles artifact ingestion."""
125
+
126
+ def __init__(self, working_tier: Any, episodic_tier: Any) -> None:
127
+ self.working = working_tier
128
+ self.episodic = episodic_tier
129
+ self._is_active = False
130
+
131
+ async def startup(self) -> None:
132
+ """Boot databases and establish active connection boundaries."""
133
+ logger.info("Initializing Hierarchical Memory Tiers...")
134
+ self._is_active = True
135
+
136
+ async def shutdown(self) -> None:
137
+ """Flush working memory turns into episodic SQLite, then terminate.
138
+
139
+ Previously this was a no-op (only logged a message). Now it
140
+ iterates all live working turns and persists them to the episodic
141
+ tier before clearing them, so turn history survives process exit.
142
+ """
143
+ logger.info("Flushing transient working memory buffers before shutdown...")
144
+
145
+ if self.working and self.episodic:
146
+ session_id = getattr(self.working, "session_id", "default")
147
+ # Evict stale turns first so we don't persist garbage
148
+ if hasattr(self.working, "evict_expired"):
149
+ self.working.evict_expired()
150
+
151
+ turns = self.working.get_turns()
152
+ if turns:
153
+ logger.info(
154
+ "Persisting %d working memory turns to episodic SQLite [session=%s]",
155
+ len(turns),
156
+ session_id,
157
+ )
158
+ for turn in turns:
159
+ try:
160
+ await self.episodic.add_turn(
161
+ session_id=session_id,
162
+ role=turn.role,
163
+ content=turn.content,
164
+ metadata=turn.metadata,
165
+ )
166
+ except Exception as exc:
167
+ logger.warning("Failed to flush turn to episodic memory: %s", exc)
168
+
169
+ self._is_active = False
170
+
171
+ async def get_recent_context(self, session_id: str, limit: int = 10) -> list[dict[str, Any]]:
172
+ """Return the most recent episodic turns for a session.
173
+
174
+ This is the *minimal episodic read path* — it reads directly from
175
+ the episodic SQLite tier so orchestrators can hydrate context from
176
+ previous sessions without coupling to the full retrieval stack.
177
+
178
+ Parameters
179
+ ----------
180
+ session_id:
181
+ The session whose history to fetch.
182
+ limit:
183
+ Maximum number of turns to return (most recent first).
184
+
185
+ Returns
186
+ -------
187
+ list[dict]:
188
+ Each dict has keys ``role``, ``content``, ``timestamp``,
189
+ ``metadata``. Returns an empty list if the episodic tier is
190
+ unavailable or has no history for this session.
191
+ """
192
+ if not self.episodic:
193
+ return []
194
+
195
+ try:
196
+ turns = await self.episodic.get_turns(session_id)
197
+ recent = turns[-limit:]
198
+ return [
199
+ {
200
+ "role": t.role,
201
+ "content": t.content,
202
+ "timestamp": t.timestamp,
203
+ "metadata": t.metadata,
204
+ }
205
+ for t in recent
206
+ ]
207
+ except Exception as exc:
208
+ logger.warning("Failed to read episodic context [session=%s]: %s", session_id, exc)
209
+ return []
210
+
211
+ def ingest(self, artifact: MemoryArtifact) -> None:
212
+ """Ingest a finalized memory artifact into working and episodic tiers."""
213
+ session_id = artifact.metadata.get("run_id") or "default"
214
+ if self.working:
215
+ self.working.add_turn(
216
+ role="system",
217
+ content=artifact.content,
218
+ metadata=artifact.metadata,
219
+ )
220
+ if self.episodic:
221
+ self.episodic.add_turn(
222
+ session_id=session_id,
223
+ role="system",
224
+ content=artifact.content,
225
+ metadata=artifact.metadata,
226
+ )
227
+
228
+ def summary(self) -> dict[str, Any]:
229
+ """Retrieve dynamic health and retention stats across all tiers."""
230
+ return {
231
+ "working_turns": len(self.working.get_turns()) if self.working else 0,
232
+ "working_logs": len(self.working.get_execution_logs()) if self.working else 0,
233
+ "is_active": self._is_active,
234
+ }
235
+
236
+
237
+ # ─────────────────────────────────────────────────────────────────────────────
238
+ # Phase 2a: Enhanced MemoryLifecycleManager
239
+ # ─────────────────────────────────────────────────────────────────────────────
240
+
241
+
242
+ class MemoryLifecycleManager:
243
+ """Coordinates all memory tiers: working, episodic, semantic, and lineage.
244
+
245
+ Provides unified read (retrieve) and write (record_turn) interfaces,
246
+ vitality-based filtering, and health metrics.
247
+ """
248
+
249
+ def __init__(
250
+ self,
251
+ working_tier: Any,
252
+ episodic_memory: Any,
253
+ semantic_memory: Any,
254
+ embedding_pipeline: Any,
255
+ lineage_tier: Any,
256
+ episodic_session_memory: Any | None = None,
257
+ ) -> None:
258
+ """Initialize with all memory tier implementations.
259
+
260
+ Parameters
261
+ ----------
262
+ working_tier:
263
+ In-process turn store (WorkingMemoryTier).
264
+ episodic_memory:
265
+ SQLite-backed session/turn store (EpisodicMemory).
266
+ semantic_memory:
267
+ LanceDB-backed semantic store (SemanticMemory).
268
+ embedding_pipeline:
269
+ Background embedding queue (EmbeddingPipeline).
270
+ lineage_tier:
271
+ Decision and failure store (LineageMemoryTier).
272
+ episodic_session_memory:
273
+ Optional session memory tier (EpisodicMemoryTier, legacy).
274
+ """
275
+ self.working = working_tier
276
+ self.episodic_memory = episodic_memory
277
+ self.semantic_memory = semantic_memory
278
+ self.embedding_pipeline = embedding_pipeline
279
+ self.lineage = lineage_tier
280
+ self.episodic_session_memory = episodic_session_memory
281
+
282
+ from velune.memory.vitality import VitalityClassifier
283
+
284
+ self._vitality = VitalityClassifier()
285
+ self._session_count = 0
286
+ self._is_active = False
287
+
288
+ async def startup(self) -> None:
289
+ """Initialize all memory tiers."""
290
+ logger.info("MemoryLifecycleManager: starting up...")
291
+ self._is_active = True
292
+
293
+ async def shutdown(self) -> None:
294
+ """Flush queues and close connections."""
295
+ logger.info("MemoryLifecycleManager: shutting down...")
296
+ if self.working and self.episodic_memory:
297
+ session_id = getattr(self.working, "session_id", "default")
298
+ if hasattr(self.working, "evict_expired"):
299
+ self.working.evict_expired()
300
+ turns = self.working.get_turns()
301
+ if turns:
302
+ logger.debug("Flushing %d working turns to episodic", len(turns))
303
+ for turn in turns:
304
+ try:
305
+ await self.episodic_memory.record_turn(
306
+ session_id=session_id,
307
+ role=turn.role,
308
+ content=turn.content,
309
+ )
310
+ except Exception as exc:
311
+ logger.warning("Failed to flush turn: %s", exc)
312
+ self._is_active = False
313
+
314
+ async def record_turn(
315
+ self,
316
+ session_id: str,
317
+ role: str,
318
+ content: str,
319
+ model: str | None = None,
320
+ tokens: int | None = None,
321
+ workspace_root: str = "",
322
+ ) -> str:
323
+ """Record a conversation turn across all applicable tiers.
324
+
325
+ Writes to episodic SQLite, enqueues for semantic embedding,
326
+ and logs to working memory. Triggers compaction if thresholds are met.
327
+
328
+ Returns the turn ID.
329
+ """
330
+ turn_id = ""
331
+ try:
332
+ if self.episodic_memory:
333
+ turn_id = await self.episodic_memory.record_turn(
334
+ session_id=session_id,
335
+ role=role,
336
+ content=content,
337
+ model=model,
338
+ tokens=tokens,
339
+ )
340
+ except Exception as exc:
341
+ logger.warning("Failed to record turn to episodic: %s", exc)
342
+
343
+ if self.semantic_memory and turn_id:
344
+ try:
345
+ from velune.memory.tiers.episodic import Turn
346
+
347
+ turn = Turn(
348
+ id=turn_id,
349
+ session_id=session_id,
350
+ turn_index=0,
351
+ role=role,
352
+ content=content,
353
+ model_used=model,
354
+ tokens_used=tokens,
355
+ created_at=time.time(),
356
+ )
357
+ self.semantic_memory.index_turn(turn, workspace_root)
358
+ except Exception as exc:
359
+ logger.debug("Failed to enqueue turn for embedding: %s", exc)
360
+
361
+ if self.working:
362
+ try:
363
+ self.working.add_turn(role, content, {"model": model, "tokens": tokens})
364
+ except Exception as exc:
365
+ logger.warning("Failed to add turn to working: %s", exc)
366
+
367
+ # Check for compaction trigger (non-blocking)
368
+ await self._check_and_trigger_compaction(session_id)
369
+
370
+ return turn_id
371
+
372
+ async def _check_and_trigger_compaction(self, session_id: str) -> None:
373
+ """Check if compaction should be triggered and schedule it as background task.
374
+
375
+ Parameters
376
+ ----------
377
+ session_id:
378
+ Session ID for the current session
379
+ """
380
+ if not self.working:
381
+ return
382
+
383
+ try:
384
+ turns = self.working.get_turns()
385
+ turn_count = len(turns)
386
+
387
+ # Estimate current token count (rough)
388
+ current_tokens = sum(len(t.content) // 4 for t in turns)
389
+
390
+ # Create compactor if needed
391
+ if not hasattr(self, "_compactor"):
392
+ from velune.memory.compaction import ContextCompactor
393
+
394
+ # Use first available provider (or default)
395
+ provider = None # Will be set when needed
396
+ self._compactor = ContextCompactor(
397
+ provider=provider,
398
+ working_tier=self.working,
399
+ episodic_memory=self.episodic_memory,
400
+ max_context_tokens=100000,
401
+ )
402
+
403
+ # Check if compaction should trigger
404
+ should_compact = await self._compactor.should_compact(
405
+ turn_count=turn_count,
406
+ current_token_count=current_tokens,
407
+ session_end=False,
408
+ )
409
+
410
+ if should_compact:
411
+ # Schedule compaction as background task (non-blocking)
412
+ asyncio.create_task(self._perform_compaction(session_id))
413
+ except Exception as exc:
414
+ logger.debug("Error checking compaction trigger: %s", exc)
415
+
416
+ async def _perform_compaction(self, session_id: str) -> None:
417
+ """Perform compaction asynchronously in the background.
418
+
419
+ Parameters
420
+ ----------
421
+ session_id:
422
+ Session ID for the current session
423
+ """
424
+ try:
425
+ if hasattr(self, "_compactor"):
426
+ stats = await self._compactor.compact(session_id)
427
+ if stats:
428
+ logger.info(
429
+ f"Compaction completed: {stats.turns_compacted} turns → "
430
+ f"{stats.summary_token_count} tokens ({stats.compression_ratio:.1f}x)"
431
+ )
432
+ except Exception as exc:
433
+ logger.warning("Background compaction failed: %s", exc)
434
+
435
+ async def retrieve(
436
+ self,
437
+ query: str,
438
+ workspace_root: str,
439
+ budget: int = 4000,
440
+ ) -> RetrievedContext:
441
+ """Multi-tier retrieval: working → episodic (LIKE) → semantic (ANN).
442
+
443
+ Merges results from all tiers, filters by vitality, ranks by
444
+ (relevance × trust), and fits to token budget.
445
+
446
+ Parameters
447
+ ----------
448
+ query:
449
+ The search query.
450
+ workspace_root:
451
+ Workspace path for scoping searches.
452
+ budget:
453
+ Token budget for results (approx 4000 tokens default).
454
+
455
+ Returns
456
+ -------
457
+ RetrievedContext:
458
+ Aggregated results, sorted by relevance.
459
+ """
460
+ context = RetrievedContext(query=query, workspace_root=workspace_root)
461
+ accumulated_tokens = 0
462
+
463
+ try:
464
+ # Step 1: Working memory (current session, fastest)
465
+ if self.working:
466
+ recent = self.working.get_recent_turns(limit=10)
467
+ for turn in recent:
468
+ result = RetrievedResult(
469
+ content=turn.content,
470
+ source_type="working",
471
+ relevance_score=0.95, # High confidence for current session
472
+ trust_score=1.0,
473
+ vitality="live",
474
+ session_id=turn.session_id,
475
+ age_seconds=time.time() - turn.timestamp,
476
+ attribution="current session",
477
+ )
478
+ tokens = self._estimate_tokens(turn.content)
479
+ if accumulated_tokens + tokens <= budget:
480
+ context.results.append(result)
481
+ accumulated_tokens += tokens
482
+ else:
483
+ break
484
+ except Exception as exc:
485
+ logger.debug("Working memory search failed: %s", exc)
486
+
487
+ # Step 2: Episodic memory (SQLite LIKE, fast but imprecise)
488
+ try:
489
+ if self.episodic_memory and accumulated_tokens < budget:
490
+ episodic_results = await self.episodic_memory.search_by_content(
491
+ query, workspace_root, limit=10
492
+ )
493
+ for turn in episodic_results:
494
+ age = time.time() - turn.created_at
495
+ result = RetrievedResult(
496
+ content=turn.content,
497
+ source_type="episodic",
498
+ relevance_score=0.7,
499
+ trust_score=0.8,
500
+ vitality="zombie" if age > 604800 else "live", # 7 days
501
+ session_id=turn.session_id,
502
+ age_seconds=age,
503
+ attribution=self._format_age(age),
504
+ )
505
+ tokens = self._estimate_tokens(turn.content)
506
+ if accumulated_tokens + tokens <= budget:
507
+ context.results.append(result)
508
+ accumulated_tokens += tokens
509
+ else:
510
+ break
511
+ except Exception as exc:
512
+ logger.debug("Episodic search failed: %s", exc)
513
+
514
+ # Step 3: Semantic memory (LanceDB ANN, slower but precise)
515
+ try:
516
+ if self.semantic_memory and accumulated_tokens < budget and self.embedding_pipeline:
517
+ semantic_results = await self.semantic_memory.search(query, workspace_root, limit=5)
518
+ for mem in semantic_results:
519
+ # Map semantic memory's vitality to our enum
520
+ vitality = "live"
521
+ trust = mem.trust_score
522
+ if mem.age_seconds > 2592000: # 30 days
523
+ vitality = "archived"
524
+ trust *= 0.2
525
+ elif mem.age_seconds > 604800: # 7 days
526
+ vitality = "zombie"
527
+ trust *= 0.6
528
+
529
+ result = RetrievedResult(
530
+ content=mem.content,
531
+ source_type="semantic",
532
+ relevance_score=1.0 - mem.distance,
533
+ trust_score=trust,
534
+ vitality=vitality,
535
+ session_id=mem.session_id,
536
+ age_seconds=mem.age_seconds,
537
+ attribution=mem.attribution,
538
+ )
539
+ tokens = self._estimate_tokens(mem.content)
540
+ if accumulated_tokens + tokens <= budget:
541
+ context.results.append(result)
542
+ accumulated_tokens += tokens
543
+ else:
544
+ break
545
+ except Exception as exc:
546
+ logger.debug("Semantic search failed: %s", exc)
547
+
548
+ # Step 4: Rank by (relevance × trust) and sort
549
+ for result in context.results:
550
+ result.relevance_score *= result.trust_score
551
+
552
+ context.results.sort(key=lambda r: r.relevance_score, reverse=True)
553
+ context.total_tokens = accumulated_tokens
554
+
555
+ logger.debug(
556
+ "Retrieved %d results (%d tokens) for query '%s'",
557
+ len(context.results),
558
+ accumulated_tokens,
559
+ query[:50],
560
+ )
561
+ return context
562
+
563
+ async def get_working_context(self, session_id: str, limit: int = 10) -> list[Any]:
564
+ """Return the N most recent turns from working memory.
565
+
566
+ Parameters
567
+ ----------
568
+ session_id:
569
+ The session whose context to return.
570
+ limit:
571
+ Maximum number of turns to return.
572
+
573
+ Returns
574
+ -------
575
+ list:
576
+ Recent turns in chronological order.
577
+ """
578
+ if not self.working:
579
+ return []
580
+ try:
581
+ return self.working.get_recent_turns(limit)
582
+ except Exception as exc:
583
+ logger.warning("Failed to get working context: %s", exc)
584
+ return []
585
+
586
+ async def get_lineage_warnings(self, query: str) -> tuple[list[Decision], list[Failure]]:
587
+ """Retrieve architectural decisions and failed experiments related to query.
588
+
589
+ Returns two lists: approved decisions and past failures that might inform
590
+ the current decision.
591
+
592
+ Parameters
593
+ ----------
594
+ query:
595
+ The query context (e.g., a subsystem name or feature description).
596
+
597
+ Returns
598
+ -------
599
+ tuple[list[Decision], list[Failure]]:
600
+ (decisions, failures) both sorted by relevance.
601
+ """
602
+ decisions: list[Decision] = []
603
+ failures: list[Failure] = []
604
+
605
+ if not self.lineage:
606
+ return decisions, failures
607
+
608
+ try:
609
+ # This is a stub; full implementation requires searching lineage by query
610
+ # For now, return empty lists and log the intention.
611
+ logger.debug("Lineage warnings requested for query: %s", query)
612
+ except Exception as exc:
613
+ logger.warning("Failed to retrieve lineage warnings: %s", exc)
614
+
615
+ return decisions, failures
616
+
617
+ async def health(self) -> MemoryHealth:
618
+ """Return health metrics across all memory tiers.
619
+
620
+ Returns
621
+ -------
622
+ MemoryHealth:
623
+ Metrics including turn counts, queue depth, and store size.
624
+ """
625
+ health = MemoryHealth(timestamp=time.time())
626
+
627
+ if self.working:
628
+ health.working_memory_turns = len(self.working.get_turns())
629
+
630
+ if self.episodic_memory:
631
+ try:
632
+ sessions = await self.episodic_memory.list_recent_sessions(
633
+ workspace_root="", limit=1000
634
+ )
635
+ health.episodic_sessions = len(sessions)
636
+ except Exception as exc:
637
+ logger.debug("Failed to count episodic sessions: %s", exc)
638
+
639
+ if self.embedding_pipeline:
640
+ health.embedding_queue_depth = self.embedding_pipeline._queue.qsize()
641
+
642
+ if hasattr(self.semantic_memory, "_store") and self.semantic_memory._store:
643
+ try:
644
+ import os
645
+
646
+ store_path = getattr(self.semantic_memory._store, "_path", None)
647
+ if store_path and os.path.isdir(store_path):
648
+ total_size = sum(
649
+ os.path.getsize(os.path.join(root, f))
650
+ for root, _, files in os.walk(store_path)
651
+ for f in files
652
+ )
653
+ health.lancedb_size_mb = total_size / (1024 * 1024)
654
+ except Exception as exc:
655
+ logger.debug("Failed to measure LanceDB size: %s", exc)
656
+
657
+ return health
658
+
659
+ @staticmethod
660
+ def _estimate_tokens(text: str) -> int:
661
+ """Rough token estimate (4 chars ≈ 1 token)."""
662
+ return max(1, len(text) // 4)
663
+
664
+ @staticmethod
665
+ def _format_age(seconds: float) -> str:
666
+ """Return a human-readable relative age string."""
667
+ minutes = seconds / 60
668
+ hours = minutes / 60
669
+ days = hours / 24
670
+ if days >= 2:
671
+ return f"{int(days)} days ago"
672
+ if days >= 1:
673
+ return "yesterday"
674
+ if hours >= 2:
675
+ return f"{int(hours)} hours ago"
676
+ if hours >= 1:
677
+ return "an hour ago"
678
+ if minutes >= 2:
679
+ return f"{int(minutes)} minutes ago"
680
+ return "just now"