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,475 @@
1
+ """Semantic Memory Tier (Tier 3).
2
+
3
+ Qdrant-backed semantic store managing dense code symbol embeddings,
4
+ summaries, and payload-filtered contextual searches.
5
+
6
+ Phase 2a also adds :class:`SemanticMemory` — a LanceDB-backed tier with an
7
+ async embedding pipeline, background indexing queue, and REPL retrieval.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+ import os
14
+ import time
15
+ import uuid
16
+ from typing import Any
17
+
18
+ # NOTE: qdrant_client (and its compiled local-mode backend) is imported lazily
19
+ # inside _ensure_client(). Importing it at module load — and constructing the
20
+ # client — was a multi-second cost on the startup path, especially with the
21
+ # store on a cloud-synced drive. Vectors are needed only once a memory/retrieval
22
+ # operation actually runs, so we defer both the import and the connection.
23
+
24
+ logger = logging.getLogger("velune.memory.tiers.semantic")
25
+
26
+
27
+ def _qmodels() -> Any:
28
+ """Lazily import qdrant http models."""
29
+ from qdrant_client.http import models as qmodels
30
+
31
+ return qmodels
32
+
33
+
34
+ class SemanticMemoryTier:
35
+ """Tier 3: Semantic store using Qdrant (lazy-initialized, degradable).
36
+
37
+ The Qdrant client is created on first access rather than at construction.
38
+ This keeps the vector backend off the critical startup path and lets the
39
+ rest of the system boot even when the vector store is unavailable. Set
40
+ ``VELUNE_SKIP_QDRANT=1`` to force a no-op degraded mode (useful for fast dev
41
+ iteration); all operations then become safe no-ops and searches return ``[]``.
42
+ """
43
+
44
+ def __init__(
45
+ self,
46
+ location: str = ":memory:",
47
+ url: str | None = None,
48
+ api_key: str | None = None,
49
+ path: str | None = None,
50
+ ) -> None:
51
+ self._location = location
52
+ self._url = url
53
+ self._api_key = api_key
54
+ self._path = path
55
+ self._client: Any = None
56
+ self._degraded = os.environ.get("VELUNE_SKIP_QDRANT", "").lower() in ("1", "true", "yes")
57
+ if self._degraded:
58
+ logger.warning(
59
+ "VELUNE_SKIP_QDRANT set — semantic memory running in degraded (no-op) mode."
60
+ )
61
+
62
+ def _ensure_client(self) -> Any:
63
+ """Create (once) and return the Qdrant client, or None in degraded mode."""
64
+ if self._degraded:
65
+ return None
66
+ if self._client is not None:
67
+ return self._client
68
+ try:
69
+ from qdrant_client import QdrantClient
70
+
71
+ if self._url:
72
+ logger.debug("Initializing Qdrant remote client at %s", self._url)
73
+ self._client = QdrantClient(url=self._url, api_key=self._api_key)
74
+ elif self._path:
75
+ logger.debug("Initializing Qdrant in-process local storage at %s", self._path)
76
+ self._client = QdrantClient(path=self._path)
77
+ else:
78
+ logger.debug("Initializing Qdrant volatile in-memory client (:memory:)")
79
+ self._client = QdrantClient(location=self._location)
80
+ except Exception as exc:
81
+ # Degrade gracefully rather than crashing the whole runtime.
82
+ logger.error("Qdrant initialization failed; semantic memory degraded: %s", exc)
83
+ self._degraded = True
84
+ return None
85
+ return self._client
86
+
87
+ @property
88
+ def client(self) -> Any:
89
+ """Backward-compatible accessor; triggers lazy initialization."""
90
+ return self._ensure_client()
91
+
92
+ def create_collection(
93
+ self,
94
+ collection_name: str,
95
+ vector_size: int = 1536,
96
+ distance_metric: str = "Cosine",
97
+ ) -> None:
98
+ """Create a new collection if it does not already exist."""
99
+ client = self._ensure_client()
100
+ if client is None:
101
+ return # Degraded mode
102
+ qmodels = _qmodels()
103
+ try:
104
+ # Check if exists
105
+ collections = client.get_collections().collections
106
+ exists = any(c.name == collection_name for c in collections)
107
+
108
+ if not exists:
109
+ metric = qmodels.Distance.COSINE
110
+ if distance_metric.lower() == "euclidean":
111
+ metric = qmodels.Distance.EUCLID
112
+ elif distance_metric.lower() == "dot":
113
+ metric = qmodels.Distance.DOT
114
+
115
+ client.create_collection(
116
+ collection_name=collection_name,
117
+ vectors_config=qmodels.VectorParams(
118
+ size=vector_size,
119
+ distance=metric,
120
+ ),
121
+ )
122
+ logger.debug("Created Qdrant collection: %s", collection_name)
123
+ except Exception as e:
124
+ logger.error("Failed to create collection %s: %s", collection_name, e)
125
+
126
+ def _clean_id(self, p_id: int | str) -> int | str:
127
+ """Ensure the point ID is a valid Qdrant ID: a 64-bit int or a valid UUID string."""
128
+ if isinstance(p_id, int):
129
+ return p_id
130
+ if isinstance(p_id, str):
131
+ try:
132
+ # Check if it is a valid UUID
133
+ uuid.UUID(p_id)
134
+ return p_id
135
+ except ValueError:
136
+ # Deterministic UUID string from arbitrary string
137
+ return str(uuid.uuid5(uuid.NAMESPACE_DNS, p_id))
138
+ return hash(p_id) % (2**63 - 1)
139
+
140
+ def upsert_points(
141
+ self,
142
+ collection_name: str,
143
+ ids: list[int | str],
144
+ vectors: list[list[float]],
145
+ payloads: list[dict[str, Any]],
146
+ ) -> None:
147
+ """Upsert structural code or memory embedding points into the collection."""
148
+ client = self._ensure_client()
149
+ if client is None:
150
+ return # Degraded mode
151
+ qmodels = _qmodels()
152
+ if vectors:
153
+ dims = {len(v) for v in vectors}
154
+ if len(dims) > 1:
155
+ raise ValueError(f"Mixed embedding dimensions in batch: {dims}")
156
+
157
+ points = []
158
+ for i, (p_id, vec, pay) in enumerate(zip(ids, vectors, payloads, strict=False)):
159
+ # Ensure unique IDs are formatted correctly
160
+ point_id = self._clean_id(p_id) if p_id is not None else i
161
+ points.append(
162
+ qmodels.PointStruct(
163
+ id=point_id,
164
+ vector=vec,
165
+ payload=pay,
166
+ )
167
+ )
168
+
169
+ try:
170
+ client.upsert(
171
+ collection_name=collection_name,
172
+ points=points,
173
+ )
174
+ logger.debug("Successfully upserted %d points into %s", len(points), collection_name)
175
+ except Exception as e:
176
+ logger.error("Failed upserting vectors in collection %s: %s", collection_name, e)
177
+
178
+ def search_similarity(
179
+ self,
180
+ collection_name: str,
181
+ query_vector: list[float],
182
+ limit: int = 5,
183
+ payload_filter: dict[str, Any] | None = None,
184
+ ) -> list[dict[str, Any]]:
185
+ """
186
+ Query vector similarities under optional key-value metadata payload filter matching.
187
+ """
188
+ client = self._ensure_client()
189
+ if client is None:
190
+ return [] # Degraded mode
191
+ qmodels = _qmodels()
192
+ q_filter = None
193
+ if payload_filter:
194
+ conditions = []
195
+ for key, val in payload_filter.items():
196
+ conditions.append(
197
+ qmodels.FieldCondition(
198
+ key=key,
199
+ match=qmodels.MatchValue(value=val),
200
+ )
201
+ )
202
+ q_filter = qmodels.Filter(must=conditions)
203
+
204
+ try:
205
+ results = client.query_points(
206
+ collection_name=collection_name,
207
+ query=query_vector,
208
+ limit=limit,
209
+ query_filter=q_filter,
210
+ ).points
211
+
212
+ output = []
213
+ for item in results:
214
+ output.append(
215
+ {
216
+ "id": item.id,
217
+ "score": item.score,
218
+ "payload": item.payload or {},
219
+ }
220
+ )
221
+ return output
222
+ except Exception as e:
223
+ logger.error("Semantic search failure on %s: %s", collection_name, e)
224
+ return []
225
+
226
+ def delete_points(self, collection_name: str, ids: list[int | str]) -> None:
227
+ """Delete specific vectors by their identifier."""
228
+ client = self._ensure_client()
229
+ if client is None:
230
+ return # Degraded mode
231
+ qmodels = _qmodels()
232
+ try:
233
+ cleaned_ids = [self._clean_id(p_id) for p_id in ids]
234
+ client.delete(
235
+ collection_name=collection_name,
236
+ points_selector=qmodels.PointIdsList(points=cleaned_ids),
237
+ )
238
+ except Exception as e:
239
+ logger.error("Failed to delete points in %s: %s", collection_name, e)
240
+
241
+ def delete_by_payload(self, collection_name: str, payload_filter: dict[str, Any]) -> None:
242
+ """Delete points matching a payload filter."""
243
+ client = self._ensure_client()
244
+ if client is None:
245
+ return # Degraded mode
246
+ qmodels = _qmodels()
247
+ try:
248
+ conditions = []
249
+ for key, val in payload_filter.items():
250
+ conditions.append(
251
+ qmodels.FieldCondition(
252
+ key=key,
253
+ match=qmodels.MatchValue(value=val),
254
+ )
255
+ )
256
+ q_filter = qmodels.Filter(must=conditions)
257
+ client.delete(
258
+ collection_name=collection_name,
259
+ points_selector=qmodels.FilterSelector(filter=q_filter),
260
+ )
261
+ logger.debug(
262
+ "Successfully deleted points matching filter %s from %s",
263
+ payload_filter,
264
+ collection_name,
265
+ )
266
+ except Exception as e:
267
+ logger.error("Failed to delete points by payload in %s: %s", collection_name, e)
268
+
269
+
270
+ # ─────────────────────────────────────────────────────────────────────────────
271
+ # Phase 2a: LanceDB-backed SemanticMemory
272
+ # ─────────────────────────────────────────────────────────────────────────────
273
+
274
+
275
+ class RetrievedMemory:
276
+ """A semantically matched memory returned to the REPL's context assembly."""
277
+
278
+ __slots__ = (
279
+ "content",
280
+ "source_type",
281
+ "distance",
282
+ "trust_score",
283
+ "session_id",
284
+ "age_seconds",
285
+ "attribution",
286
+ )
287
+
288
+ def __init__(
289
+ self,
290
+ content: str,
291
+ source_type: str,
292
+ distance: float,
293
+ trust_score: float,
294
+ session_id: str,
295
+ age_seconds: float,
296
+ attribution: str,
297
+ ) -> None:
298
+ self.content = content
299
+ self.source_type = source_type
300
+ self.distance = distance
301
+ self.trust_score = trust_score
302
+ self.session_id = session_id
303
+ self.age_seconds = age_seconds
304
+ self.attribution = attribution
305
+
306
+
307
+ def _format_age(seconds: float) -> str:
308
+ """Return a human-readable relative age string for *seconds* elapsed."""
309
+ minutes = seconds / 60
310
+ hours = minutes / 60
311
+ days = hours / 24
312
+ if days >= 2:
313
+ return f"{int(days)} days ago"
314
+ if days >= 1:
315
+ return "yesterday"
316
+ if hours >= 2:
317
+ return f"{int(hours)} hours ago"
318
+ if hours >= 1:
319
+ return "an hour ago"
320
+ if minutes >= 2:
321
+ return f"{int(minutes)} minutes ago"
322
+ return "just now"
323
+
324
+
325
+ class SemanticMemory:
326
+ """Phase-2a semantic memory backed by LanceDB and an async embedding pipeline.
327
+
328
+ ``index_turn()`` is intentionally non-blocking: it enqueues the turn and
329
+ returns immediately. The slow Ollama call and LanceDB write happen in a
330
+ background worker task owned by :class:`~velune.memory.embedding_pipeline.EmbeddingPipeline`.
331
+
332
+ Usage
333
+ -----
334
+ * Call ``await memory.search(query, workspace_root)`` from the REPL to retrieve
335
+ semantically similar past interactions before calling the model.
336
+ * Call ``memory.index_turn(turn, workspace_root)`` after each conversation turn
337
+ (non-blocking).
338
+ * Call ``await memory.subscribe_to_bus(bus, workspace_root)`` to auto-index
339
+ turns via ``ConversationTurn`` events.
340
+ """
341
+
342
+ def __init__(self, store: Any, pipeline: Any) -> None:
343
+ self._store = store
344
+ self._pipeline = pipeline
345
+
346
+ # ── Search ─────────────────────────────────────────────────────────────────
347
+
348
+ async def search(
349
+ self,
350
+ query: str,
351
+ workspace_root: str,
352
+ limit: int = 5,
353
+ ) -> list[RetrievedMemory]:
354
+ """Embed *query* and return the *limit* most semantically similar memories."""
355
+ if not self._pipeline or not self._store:
356
+ return []
357
+ try:
358
+ embedding = await self._pipeline.embed_text(query)
359
+ except Exception as exc:
360
+ logger.debug("SemanticMemory.search — embedding failed: %s", exc)
361
+ return []
362
+
363
+ try:
364
+ results = await self._store.search(
365
+ embedding, limit=limit, workspace_root=workspace_root
366
+ )
367
+ except Exception as exc:
368
+ logger.debug("SemanticMemory.search — LanceDB query failed: %s", exc)
369
+ return []
370
+
371
+ now = time.time()
372
+ memories: list[RetrievedMemory] = []
373
+ for r in results:
374
+ age = max(0.0, now - r.created_at)
375
+ memories.append(
376
+ RetrievedMemory(
377
+ content=r.content,
378
+ source_type=r.source_type,
379
+ distance=r.distance,
380
+ trust_score=r.trust_score,
381
+ session_id=r.session_id,
382
+ age_seconds=age,
383
+ attribution=_format_age(age),
384
+ )
385
+ )
386
+ return memories
387
+
388
+ # ── Indexing ──────────────────────────────────────────────────────────────
389
+
390
+ def index_turn(self, turn: Any, workspace_root: str = "") -> None:
391
+ """Non-blocking: enqueue *turn* for background embedding and indexing."""
392
+ if not self._pipeline:
393
+ return
394
+ from velune.memory.embedding_pipeline import EmbedQueueItem
395
+
396
+ role = getattr(turn, "role", "unknown")
397
+ self._pipeline.enqueue(
398
+ EmbedQueueItem(
399
+ record_id=f"mem-{uuid.uuid4().hex[:12]}",
400
+ turn_id=getattr(turn, "id", ""),
401
+ session_id=getattr(turn, "session_id", ""),
402
+ role=role,
403
+ content=getattr(turn, "content", ""),
404
+ source_type=f"turn_{role}",
405
+ workspace_root=workspace_root,
406
+ created_at=getattr(turn, "created_at", time.time()),
407
+ )
408
+ )
409
+
410
+ async def index_session_summary(
411
+ self,
412
+ session_id: str,
413
+ summary: str,
414
+ workspace_root: str = "",
415
+ ) -> None:
416
+ """Non-blocking: enqueue a session summary for background embedding."""
417
+ if not self._pipeline:
418
+ return
419
+ from velune.memory.embedding_pipeline import EmbedQueueItem
420
+
421
+ self._pipeline.enqueue(
422
+ EmbedQueueItem(
423
+ record_id=f"sum-{uuid.uuid4().hex[:12]}",
424
+ turn_id="",
425
+ session_id=session_id,
426
+ role="system",
427
+ content=summary,
428
+ source_type="session_summary",
429
+ workspace_root=workspace_root,
430
+ created_at=time.time(),
431
+ )
432
+ )
433
+
434
+ # ── Maintenance ────────────────────────────────────────────────────────────
435
+
436
+ async def prune_low_vitality(self, threshold: float = 0.2) -> int:
437
+ """Delete stored entries whose trust_score is below *threshold*."""
438
+ if not self._store:
439
+ return 0
440
+ count = await self._store.prune_by_trust(threshold)
441
+ if count:
442
+ logger.info("SemanticMemory pruned %d low-vitality entries", count)
443
+ return count
444
+
445
+ # ── Event bus wiring ──────────────────────────────────────────────────────
446
+
447
+ async def subscribe_to_bus(self, bus: Any, workspace_root: str = "") -> None:
448
+ """Subscribe an async handler to ``ConversationTurn`` events on *bus*.
449
+
450
+ Each event enqueues the turn for background embedding — no blocking.
451
+ """
452
+
453
+ def _on_turn_sync(event: Any) -> None:
454
+ data = event.data
455
+ content = data.get("content")
456
+ if not content:
457
+ return
458
+ role = data.get("role", "unknown")
459
+ from velune.memory.embedding_pipeline import EmbedQueueItem
460
+
461
+ self._pipeline.enqueue(
462
+ EmbedQueueItem(
463
+ record_id=f"mem-{uuid.uuid4().hex[:12]}",
464
+ turn_id=data.get("turn_id", ""),
465
+ session_id=data.get("session_id", ""),
466
+ role=role,
467
+ content=content,
468
+ source_type=f"turn_{role}",
469
+ workspace_root=data.get("workspace_root", workspace_root),
470
+ created_at=time.time(),
471
+ )
472
+ )
473
+
474
+ await bus.subscribe("ConversationTurn", _on_turn_sync)
475
+ logger.debug("SemanticMemory subscribed to ConversationTurn events")
@@ -0,0 +1,168 @@
1
+ """Working Memory Tier (Tier 1).
2
+
3
+ Fast, in-process storage for the active session, conversation turns,
4
+ and transient execution logs.
5
+
6
+ Phase 1 repairs:
7
+ * Session isolation: each ``WorkingMemoryTier`` instance is bound to an
8
+ explicit ``session_id``. Only turns belonging to that session are
9
+ returned by ``get_turns()`` and ``get_recent_turns()``.
10
+ * TTL eviction: turns older than ``ttl_seconds`` are treated as expired
11
+ and removed by ``evict_expired()``. The lifecycle coordinator can call
12
+ this on shutdown or before each flush to episodic SQLite.
13
+ * ``is_expired()`` returns True if *all* turns in the session have aged
14
+ past the TTL, allowing the lifecycle to reclaim dead sessions.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import logging
20
+ import time
21
+ from typing import Any
22
+
23
+ from pydantic import BaseModel, Field
24
+
25
+ logger = logging.getLogger("velune.memory.tiers.working")
26
+
27
+ # Default TTL: 2 hours. Callers may pass a tighter or looser value.
28
+ _DEFAULT_TTL_SECONDS: float = 7200.0
29
+
30
+
31
+ class MemoryTurn(BaseModel):
32
+ """A single turn in the working memory."""
33
+
34
+ role: str
35
+ content: str
36
+ timestamp: float = Field(default_factory=time.time)
37
+ metadata: dict[str, Any] = Field(default_factory=dict)
38
+ session_id: str = Field(default="default")
39
+
40
+
41
+ class WorkingMemoryTier:
42
+ """Tier 1: Fast, in-memory transient store for the active session.
43
+
44
+ Parameters
45
+ ----------
46
+ session_id:
47
+ Logical identifier for this session. Used to namespace turns so
48
+ that multiple ``WorkingMemoryTier`` instances within the same
49
+ process never accidentally share data.
50
+ ttl_seconds:
51
+ How long (in wall-clock seconds) a turn is considered live.
52
+ Turns older than this are removed by :meth:`evict_expired`.
53
+ Defaults to 2 hours.
54
+ """
55
+
56
+ def __init__(
57
+ self,
58
+ session_id: str = "default",
59
+ ttl_seconds: float = _DEFAULT_TTL_SECONDS,
60
+ ) -> None:
61
+ self._session_id = session_id
62
+ self._ttl_seconds = ttl_seconds
63
+ self._turns: list[MemoryTurn] = []
64
+ self._state: dict[str, Any] = {}
65
+ self._execution_logs: list[dict[str, Any]] = []
66
+ self._created_at: float = time.time()
67
+
68
+ # ------------------------------------------------------------------
69
+ # Session metadata
70
+ # ------------------------------------------------------------------
71
+
72
+ @property
73
+ def session_id(self) -> str:
74
+ """The session this tier is bound to."""
75
+ return self._session_id
76
+
77
+ # ------------------------------------------------------------------
78
+ # Turn management
79
+ # ------------------------------------------------------------------
80
+
81
+ def add_turn(self, role: str, content: str, metadata: dict[str, Any] | None = None) -> None:
82
+ """Add a conversation turn to working memory."""
83
+ turn = MemoryTurn(
84
+ role=role,
85
+ content=content,
86
+ metadata=metadata or {},
87
+ session_id=self._session_id,
88
+ )
89
+ self._turns.append(turn)
90
+ logger.debug("Added turn to working memory [session=%s role=%s]", self._session_id, role)
91
+
92
+ def get_turns(self) -> list[MemoryTurn]:
93
+ """Get all turns for this session in chronological order."""
94
+ return [t for t in self._turns if t.session_id == self._session_id]
95
+
96
+ def get_recent_turns(self, limit: int = 10) -> list[MemoryTurn]:
97
+ """Get the N most recent conversation turns for this session."""
98
+ session_turns = self.get_turns()
99
+ return session_turns[-limit:]
100
+
101
+ # ------------------------------------------------------------------
102
+ # TTL eviction
103
+ # ------------------------------------------------------------------
104
+
105
+ def evict_expired(self) -> int:
106
+ """Remove all turns that have exceeded the session TTL.
107
+
108
+ Returns the number of turns evicted. Safe to call at any time;
109
+ active turns are never removed.
110
+ """
111
+ cutoff = time.time() - self._ttl_seconds
112
+ before = len(self._turns)
113
+ self._turns = [t for t in self._turns if t.timestamp >= cutoff]
114
+ evicted = before - len(self._turns)
115
+ if evicted:
116
+ logger.debug(
117
+ "Evicted %d expired turns from working memory [session=%s ttl=%.0fs]",
118
+ evicted,
119
+ self._session_id,
120
+ self._ttl_seconds,
121
+ )
122
+ return evicted
123
+
124
+ def is_expired(self) -> bool:
125
+ """Return True if this session has no live turns (all aged past TTL).
126
+
127
+ A freshly created session with zero turns is NOT considered expired —
128
+ the caller must check :meth:`get_turns` to distinguish empty-new from
129
+ empty-evicted.
130
+ """
131
+ if not self._turns:
132
+ # No turns yet; treat as live (session may still be initialising)
133
+ return False
134
+ cutoff = time.time() - self._ttl_seconds
135
+ return all(t.timestamp < cutoff for t in self._turns)
136
+
137
+ # ------------------------------------------------------------------
138
+ # State / execution log helpers (unchanged semantics)
139
+ # ------------------------------------------------------------------
140
+
141
+ def update_state(self, key: str, value: Any) -> None:
142
+ """Update transient state variables."""
143
+ self._state[key] = value
144
+
145
+ def get_state(self, key: str, default: Any = None) -> Any:
146
+ """Retrieve a transient state variable."""
147
+ return self._state.get(key, default)
148
+
149
+ def log_execution_step(self, step_name: str, payload: dict[str, Any]) -> None:
150
+ """Record a transient execution step log."""
151
+ self._execution_logs.append(
152
+ {
153
+ "step": step_name,
154
+ "payload": payload,
155
+ "timestamp": time.time(),
156
+ }
157
+ )
158
+
159
+ def get_execution_logs(self) -> list[dict[str, Any]]:
160
+ """Get all transient execution logs."""
161
+ return list(self._execution_logs)
162
+
163
+ def clear(self) -> None:
164
+ """Clear all active working memory structures."""
165
+ self._turns.clear()
166
+ self._state.clear()
167
+ self._execution_logs.clear()
168
+ logger.info("Cleared working memory tier [session=%s].", self._session_id)