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,588 @@
1
+ """Episodic Memory Tier (Tier 2).
2
+
3
+ SQLite-backed store for structured historical reads of execution steps,
4
+ intermediate tool outputs, and complete conversation traces.
5
+
6
+ All I/O is async and routed through :class:`~velune.memory.storage.sqlite_pool.SQLiteConnectionPool`
7
+ so concurrent coroutines never cause WAL write contention.
8
+
9
+ Phase 2a also introduces :class:`EpisodicMemory` — a higher-level session/turn
10
+ store with schema migrations, content search, and CognitiveBus integration.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import logging
17
+ import time
18
+ import uuid
19
+ from typing import Any
20
+
21
+ from pydantic import BaseModel, Field
22
+
23
+ from velune.memory.storage.sqlite_pool import SQLiteConnectionPool
24
+
25
+ logger = logging.getLogger("velune.memory.tiers.episodic")
26
+
27
+ _SCHEMA_SQL = """
28
+ CREATE TABLE IF NOT EXISTS conversation_turns (
29
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
30
+ session_id TEXT NOT NULL,
31
+ role TEXT NOT NULL,
32
+ content TEXT NOT NULL,
33
+ timestamp REAL NOT NULL,
34
+ metadata TEXT NOT NULL
35
+ );
36
+
37
+ CREATE TABLE IF NOT EXISTS execution_steps (
38
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
39
+ session_id TEXT NOT NULL,
40
+ step_name TEXT NOT NULL,
41
+ status TEXT NOT NULL,
42
+ payload TEXT NOT NULL,
43
+ timestamp REAL NOT NULL
44
+ );
45
+
46
+ CREATE INDEX IF NOT EXISTS idx_turns_session ON conversation_turns(session_id);
47
+ CREATE INDEX IF NOT EXISTS idx_steps_session ON execution_steps(session_id);
48
+ """
49
+
50
+
51
+ class EpisodicTurn(BaseModel):
52
+ """An episodic conversation turn stored in SQLite."""
53
+
54
+ id: int | None = None
55
+ session_id: str
56
+ role: str
57
+ content: str
58
+ timestamp: float
59
+ metadata: dict[str, Any] = Field(default_factory=dict)
60
+
61
+
62
+ class EpisodicStep(BaseModel):
63
+ """An episodic execution step stored in SQLite."""
64
+
65
+ id: int | None = None
66
+ session_id: str
67
+ step_name: str
68
+ status: str
69
+ payload: dict[str, Any] = Field(default_factory=dict)
70
+ timestamp: float
71
+
72
+
73
+ class EpisodicMemoryTier:
74
+ """Tier 2: Persisted SQLite storage for local episodic trace retrieval.
75
+
76
+ Requires a :class:`~velune.memory.storage.sqlite_pool.SQLiteConnectionPool`
77
+ that has already been started. Call ``await tier.initialize()`` before
78
+ any read or write operations; the :class:`~velune.kernel.bootstrap.RuntimeBootstrapper`
79
+ does this automatically when ``lifecycle_key`` is set.
80
+ """
81
+
82
+ def __init__(self, pool: SQLiteConnectionPool) -> None:
83
+ self._pool = pool
84
+
85
+ # ------------------------------------------------------------------
86
+ # Lifecycle
87
+ # ------------------------------------------------------------------
88
+
89
+ async def initialize(self) -> None:
90
+ """Create tables and indexes if they do not yet exist."""
91
+ await self._init_db()
92
+
93
+ async def _init_db(self) -> None:
94
+ async with self._pool.write() as conn:
95
+ for stmt in _SCHEMA_SQL.split(";"):
96
+ stmt = stmt.strip()
97
+ if stmt:
98
+ await conn.execute(stmt)
99
+ logger.debug("EpisodicMemoryTier schema initialised.")
100
+
101
+ # ------------------------------------------------------------------
102
+ # Write operations
103
+ # ------------------------------------------------------------------
104
+
105
+ async def add_turn(
106
+ self,
107
+ session_id: str,
108
+ role: str,
109
+ content: str,
110
+ metadata: dict[str, Any] | None = None,
111
+ ) -> None:
112
+ """Persist a conversation turn."""
113
+ meta_str = json.dumps(metadata or {})
114
+ try:
115
+ async with self._pool.write() as conn:
116
+ await conn.execute(
117
+ "INSERT INTO conversation_turns"
118
+ " (session_id, role, content, timestamp, metadata)"
119
+ " VALUES (?, ?, ?, ?, ?)",
120
+ (session_id, role, content, time.time(), meta_str),
121
+ )
122
+ except Exception as exc:
123
+ logger.error("Failed to insert episodic turn: %s", exc)
124
+
125
+ async def add_execution_step(
126
+ self,
127
+ session_id: str,
128
+ step_name: str,
129
+ status: str,
130
+ payload: dict[str, Any] | None = None,
131
+ ) -> None:
132
+ """Record an autonomous execution step."""
133
+ payload_str = json.dumps(payload or {})
134
+ try:
135
+ async with self._pool.write() as conn:
136
+ await conn.execute(
137
+ "INSERT INTO execution_steps"
138
+ " (session_id, step_name, status, payload, timestamp)"
139
+ " VALUES (?, ?, ?, ?, ?)",
140
+ (session_id, step_name, status, payload_str, time.time()),
141
+ )
142
+ except Exception as exc:
143
+ logger.error("Failed to insert episodic execution step: %s", exc)
144
+
145
+ async def delete_session(self, session_id: str) -> None:
146
+ """Delete all records associated with a session."""
147
+ try:
148
+ async with self._pool.write() as conn:
149
+ await conn.execute(
150
+ "DELETE FROM conversation_turns WHERE session_id = ?",
151
+ (session_id,),
152
+ )
153
+ await conn.execute(
154
+ "DELETE FROM execution_steps WHERE session_id = ?",
155
+ (session_id,),
156
+ )
157
+ logger.info("Deleted episodic history for session %s", session_id)
158
+ except Exception as exc:
159
+ logger.error("Failed to delete session history: %s", exc)
160
+
161
+ # ------------------------------------------------------------------
162
+ # Read operations
163
+ # ------------------------------------------------------------------
164
+
165
+ async def get_turns(self, session_id: str) -> list[EpisodicTurn]:
166
+ """Fetch all conversation turns for a session in chronological order."""
167
+ turns: list[EpisodicTurn] = []
168
+ try:
169
+ async with self._pool.read() as conn:
170
+ cursor = await conn.execute(
171
+ "SELECT id, session_id, role, content, timestamp, metadata"
172
+ " FROM conversation_turns"
173
+ " WHERE session_id = ?"
174
+ " ORDER BY timestamp ASC",
175
+ (session_id,),
176
+ )
177
+ rows = await cursor.fetchall()
178
+ for row in rows:
179
+ turns.append(
180
+ EpisodicTurn(
181
+ id=row["id"],
182
+ session_id=row["session_id"],
183
+ role=row["role"],
184
+ content=row["content"],
185
+ timestamp=row["timestamp"],
186
+ metadata=json.loads(row["metadata"]),
187
+ )
188
+ )
189
+ except Exception as exc:
190
+ logger.error("Failed to query episodic turns: %s", exc)
191
+ return turns
192
+
193
+ async def get_execution_steps(self, session_id: str) -> list[EpisodicStep]:
194
+ """Fetch all execution steps for a session."""
195
+ steps: list[EpisodicStep] = []
196
+ try:
197
+ async with self._pool.read() as conn:
198
+ cursor = await conn.execute(
199
+ "SELECT id, session_id, step_name, status, payload, timestamp"
200
+ " FROM execution_steps"
201
+ " WHERE session_id = ?"
202
+ " ORDER BY timestamp ASC",
203
+ (session_id,),
204
+ )
205
+ rows = await cursor.fetchall()
206
+ for row in rows:
207
+ steps.append(
208
+ EpisodicStep(
209
+ id=row["id"],
210
+ session_id=row["session_id"],
211
+ step_name=row["step_name"],
212
+ status=row["status"],
213
+ payload=json.loads(row["payload"]),
214
+ timestamp=row["timestamp"],
215
+ )
216
+ )
217
+ except Exception as exc:
218
+ logger.error("Failed to query episodic execution steps: %s", exc)
219
+ return steps
220
+
221
+
222
+ # ─────────────────────────────────────────────────────────────────────────────
223
+ # Phase 2a: session-scoped episodic memory with migrations and bus integration
224
+ # ─────────────────────────────────────────────────────────────────────────────
225
+
226
+ _V1_SCHEMA = """
227
+ CREATE TABLE IF NOT EXISTS episodic_schema_version (
228
+ version INTEGER PRIMARY KEY,
229
+ applied_at REAL NOT NULL
230
+ );
231
+ CREATE TABLE IF NOT EXISTS sessions (
232
+ id TEXT PRIMARY KEY,
233
+ workspace_root TEXT NOT NULL,
234
+ started_at REAL NOT NULL,
235
+ ended_at REAL,
236
+ model_used TEXT,
237
+ mode TEXT,
238
+ total_tokens INTEGER DEFAULT 0,
239
+ summary TEXT
240
+ );
241
+ CREATE TABLE IF NOT EXISTS turns (
242
+ id TEXT PRIMARY KEY,
243
+ session_id TEXT NOT NULL REFERENCES sessions(id),
244
+ turn_index INTEGER NOT NULL,
245
+ role TEXT NOT NULL,
246
+ content TEXT NOT NULL,
247
+ model_used TEXT,
248
+ tokens_used INTEGER,
249
+ created_at REAL NOT NULL,
250
+ embedding_id TEXT
251
+ );
252
+ CREATE TABLE IF NOT EXISTS memory_tags (
253
+ turn_id TEXT NOT NULL REFERENCES turns(id),
254
+ tag TEXT NOT NULL,
255
+ value TEXT,
256
+ PRIMARY KEY (turn_id, tag)
257
+ );
258
+ CREATE INDEX IF NOT EXISTS idx_sessions_workspace ON sessions(workspace_root);
259
+ CREATE INDEX IF NOT EXISTS idx_sessions_started ON sessions(started_at DESC);
260
+ CREATE INDEX IF NOT EXISTS idx_turns_session ON turns(session_id);
261
+ CREATE INDEX IF NOT EXISTS idx_turns_created ON turns(created_at);
262
+ CREATE INDEX IF NOT EXISTS idx_turns_role ON turns(role);
263
+ CREATE INDEX IF NOT EXISTS idx_tags_turn ON memory_tags(turn_id);
264
+ CREATE INDEX IF NOT EXISTS idx_tags_tag ON memory_tags(tag);
265
+ """
266
+
267
+ _MIGRATIONS: list[tuple[int, str]] = [
268
+ (1, _V1_SCHEMA),
269
+ ]
270
+
271
+
272
+ class Session(BaseModel):
273
+ """A REPL session record from the episodic sessions table."""
274
+
275
+ id: str
276
+ workspace_root: str
277
+ started_at: float
278
+ ended_at: float | None = None
279
+ model_used: str | None = None
280
+ mode: str | None = None
281
+ total_tokens: int = 0
282
+ summary: str | None = None
283
+ first_prompt: str | None = None # populated via JOIN, not stored in sessions
284
+
285
+
286
+ class Turn(BaseModel):
287
+ """A single conversation turn within an episodic session."""
288
+
289
+ id: str
290
+ session_id: str
291
+ turn_index: int
292
+ role: str
293
+ content: str
294
+ model_used: str | None = None
295
+ tokens_used: int | None = None
296
+ created_at: float
297
+ embedding_id: str | None = None
298
+
299
+
300
+ class EpisodicMemory:
301
+ """Phase-2a session-scoped episodic store with migrations and bus integration.
302
+
303
+ Sits on top of the same :class:`~velune.memory.storage.sqlite_pool.SQLiteConnectionPool`
304
+ as :class:`EpisodicMemoryTier` but manages its own *sessions / turns / memory_tags*
305
+ schema, versioned via a migration table.
306
+
307
+ Lifecycle
308
+ ---------
309
+ Call ``await memory.initialize()`` once (the module system does this via
310
+ the ``lifecycle_key``). Then:
311
+
312
+ * ``start_session()`` at REPL startup
313
+ * ``record_turn()`` (or event-driven via ``subscribe_to_bus()``) per exchange
314
+ * ``end_session()`` on graceful shutdown
315
+ """
316
+
317
+ def __init__(self, pool: SQLiteConnectionPool) -> None:
318
+ self._pool = pool
319
+
320
+ # ── Lifecycle ────────────────────────────────────────────────────────────
321
+
322
+ async def initialize(self) -> None:
323
+ await self._apply_migrations()
324
+
325
+ async def _apply_migrations(self) -> None:
326
+ async with self._pool.write() as conn:
327
+ await conn.execute(
328
+ "CREATE TABLE IF NOT EXISTS episodic_schema_version "
329
+ "(version INTEGER PRIMARY KEY, applied_at REAL NOT NULL)"
330
+ )
331
+
332
+ async with self._pool.read() as conn:
333
+ cursor = await conn.execute(
334
+ "SELECT COALESCE(MAX(version), 0) FROM episodic_schema_version"
335
+ )
336
+ row = await cursor.fetchone()
337
+ current: int = row[0] if row else 0
338
+
339
+ for version, sql in _MIGRATIONS:
340
+ if version > current:
341
+ async with self._pool.write() as conn:
342
+ for stmt in [s.strip() for s in sql.split(";") if s.strip()]:
343
+ await conn.execute(stmt)
344
+ await conn.execute(
345
+ "INSERT INTO episodic_schema_version (version, applied_at) VALUES (?, ?)",
346
+ (version, time.time()),
347
+ )
348
+ logger.info("EpisodicMemory: applied schema migration v%d", version)
349
+
350
+ logger.debug(
351
+ "EpisodicMemory schema at version %d",
352
+ max(v for v, _ in _MIGRATIONS),
353
+ )
354
+
355
+ # ── Session management ───────────────────────────────────────────────────
356
+
357
+ async def start_session(self, workspace_root: str, model: str, mode: str) -> str:
358
+ """Create a new session row and return its ID."""
359
+ session_id = f"ses-{uuid.uuid4().hex[:12]}"
360
+ try:
361
+ async with self._pool.write() as conn:
362
+ await conn.execute(
363
+ "INSERT INTO sessions (id, workspace_root, started_at, model_used, mode) "
364
+ "VALUES (?, ?, ?, ?, ?)",
365
+ (session_id, workspace_root, time.time(), model, mode),
366
+ )
367
+ except Exception as exc:
368
+ logger.error("Failed to start episodic session: %s", exc)
369
+ logger.debug("EpisodicMemory: started session %s", session_id)
370
+ return session_id
371
+
372
+ async def end_session(self, session_id: str) -> None:
373
+ """Close the session, recording end time and summing token usage."""
374
+ try:
375
+ async with self._pool.write() as conn:
376
+ cursor = await conn.execute(
377
+ "SELECT COALESCE(SUM(tokens_used), 0) FROM turns WHERE session_id = ?",
378
+ (session_id,),
379
+ )
380
+ row = await cursor.fetchone()
381
+ total_tokens: int = row[0] if row else 0
382
+ await conn.execute(
383
+ "UPDATE sessions SET ended_at = ?, total_tokens = ? WHERE id = ?",
384
+ (time.time(), total_tokens, session_id),
385
+ )
386
+ except Exception as exc:
387
+ logger.error("Failed to end episodic session %s: %s", session_id, exc)
388
+ logger.debug("EpisodicMemory: ended session %s", session_id)
389
+
390
+ # ── Turn recording ───────────────────────────────────────────────────────
391
+
392
+ async def record_turn(
393
+ self,
394
+ session_id: str,
395
+ role: str,
396
+ content: str,
397
+ model: str | None = None,
398
+ tokens: int | None = None,
399
+ ) -> str:
400
+ """Persist a conversation turn and return its ID."""
401
+ turn_id = f"trn-{uuid.uuid4().hex[:12]}"
402
+ try:
403
+ async with self._pool.read() as conn:
404
+ cursor = await conn.execute(
405
+ "SELECT COUNT(*) FROM turns WHERE session_id = ?",
406
+ (session_id,),
407
+ )
408
+ row = await cursor.fetchone()
409
+ turn_index: int = row[0] if row else 0
410
+
411
+ async with self._pool.write() as conn:
412
+ await conn.execute(
413
+ "INSERT INTO turns "
414
+ "(id, session_id, turn_index, role, content, model_used, tokens_used, created_at) "
415
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
416
+ (turn_id, session_id, turn_index, role, content, model, tokens, time.time()),
417
+ )
418
+ except Exception as exc:
419
+ logger.error("Failed to record episodic turn: %s", exc)
420
+ return turn_id
421
+
422
+ # ── Read operations ──────────────────────────────────────────────────────
423
+
424
+ async def get_recent_turns(self, session_id: str, limit: int = 20) -> list[Turn]:
425
+ """Return the *limit* most recent turns in chronological order."""
426
+ turns: list[Turn] = []
427
+ try:
428
+ async with self._pool.read() as conn:
429
+ cursor = await conn.execute(
430
+ "SELECT id, session_id, turn_index, role, content, "
431
+ "model_used, tokens_used, created_at, embedding_id "
432
+ "FROM turns WHERE session_id = ? "
433
+ "ORDER BY created_at DESC LIMIT ?",
434
+ (session_id, limit),
435
+ )
436
+ rows = await cursor.fetchall()
437
+ for row in reversed(rows):
438
+ turns.append(_row_to_turn(row))
439
+ except Exception as exc:
440
+ logger.error("Failed to query recent turns: %s", exc)
441
+ return turns
442
+
443
+ async def get_session_history(self, session_id: str) -> list[Turn]:
444
+ """Return all turns for a session in chronological order."""
445
+ turns: list[Turn] = []
446
+ try:
447
+ async with self._pool.read() as conn:
448
+ cursor = await conn.execute(
449
+ "SELECT id, session_id, turn_index, role, content, "
450
+ "model_used, tokens_used, created_at, embedding_id "
451
+ "FROM turns WHERE session_id = ? ORDER BY created_at ASC",
452
+ (session_id,),
453
+ )
454
+ rows = await cursor.fetchall()
455
+ for row in rows:
456
+ turns.append(_row_to_turn(row))
457
+ except Exception as exc:
458
+ logger.error("Failed to query session history: %s", exc)
459
+ return turns
460
+
461
+ async def search_by_content(
462
+ self, query: str, workspace_root: str, limit: int = 10
463
+ ) -> list[Turn]:
464
+ """LIKE-based content search across all turns in *workspace_root*."""
465
+ turns: list[Turn] = []
466
+ try:
467
+ pattern = f"%{query}%"
468
+ async with self._pool.read() as conn:
469
+ cursor = await conn.execute(
470
+ "SELECT t.id, t.session_id, t.turn_index, t.role, t.content, "
471
+ "t.model_used, t.tokens_used, t.created_at, t.embedding_id "
472
+ "FROM turns t JOIN sessions s ON t.session_id = s.id "
473
+ "WHERE s.workspace_root = ? AND t.content LIKE ? "
474
+ "ORDER BY t.created_at DESC LIMIT ?",
475
+ (workspace_root, pattern, limit),
476
+ )
477
+ rows = await cursor.fetchall()
478
+ for row in rows:
479
+ turns.append(_row_to_turn(row))
480
+ except Exception as exc:
481
+ logger.error("Failed to search turns by content: %s", exc)
482
+ return turns
483
+
484
+ async def list_recent_sessions(self, workspace_root: str, limit: int = 20) -> list[Session]:
485
+ """Return the *limit* most recent sessions for *workspace_root*."""
486
+ sessions: list[Session] = []
487
+ try:
488
+ async with self._pool.read() as conn:
489
+ cursor = await conn.execute(
490
+ "SELECT s.id, s.workspace_root, s.started_at, s.ended_at, "
491
+ "s.model_used, s.mode, s.total_tokens, s.summary, "
492
+ "(SELECT content FROM turns "
493
+ " WHERE session_id = s.id AND role = 'user' "
494
+ " ORDER BY created_at ASC LIMIT 1) AS first_prompt "
495
+ "FROM sessions s "
496
+ "WHERE s.workspace_root = ? "
497
+ "ORDER BY s.started_at DESC LIMIT ?",
498
+ (workspace_root, limit),
499
+ )
500
+ rows = await cursor.fetchall()
501
+ for row in rows:
502
+ sessions.append(
503
+ Session(
504
+ id=row["id"],
505
+ workspace_root=row["workspace_root"],
506
+ started_at=row["started_at"],
507
+ ended_at=row["ended_at"],
508
+ model_used=row["model_used"],
509
+ mode=row["mode"],
510
+ total_tokens=row["total_tokens"] or 0,
511
+ summary=row["summary"],
512
+ first_prompt=row["first_prompt"],
513
+ )
514
+ )
515
+ except Exception as exc:
516
+ logger.error("Failed to list recent sessions: %s", exc)
517
+ return sessions
518
+
519
+ async def get_session_summary(self, session_id: str) -> str | None:
520
+ """Return the stored LLM-generated summary for *session_id*, or None."""
521
+ try:
522
+ async with self._pool.read() as conn:
523
+ cursor = await conn.execute(
524
+ "SELECT summary FROM sessions WHERE id = ?", (session_id,)
525
+ )
526
+ row = await cursor.fetchone()
527
+ return row["summary"] if row else None
528
+ except Exception as exc:
529
+ logger.error("Failed to get session summary: %s", exc)
530
+ return None
531
+
532
+ async def set_session_summary(self, session_id: str, summary: str) -> None:
533
+ """Persist an LLM-generated summary for *session_id*."""
534
+ try:
535
+ async with self._pool.write() as conn:
536
+ await conn.execute(
537
+ "UPDATE sessions SET summary = ? WHERE id = ?",
538
+ (summary, session_id),
539
+ )
540
+ except Exception as exc:
541
+ logger.error("Failed to set session summary: %s", exc)
542
+
543
+ # ── Event bus wiring ─────────────────────────────────────────────────────
544
+
545
+ async def subscribe_to_bus(self, bus: Any) -> None:
546
+ """Subscribe an async handler to *ConversationTurn* events on *bus*.
547
+
548
+ The handler calls :meth:`record_turn` for each event, so REPL turn
549
+ recording is non-blocking: the REPL emits and forgets; SQLite writes
550
+ happen asynchronously in the background.
551
+ """
552
+
553
+ async def _on_turn_event(event: Any) -> None:
554
+ data = event.data
555
+ session_id = data.get("session_id")
556
+ role = data.get("role")
557
+ content = data.get("content")
558
+ if session_id and role and content:
559
+ try:
560
+ await self.record_turn(
561
+ session_id=session_id,
562
+ role=role,
563
+ content=content,
564
+ model=data.get("model_used"),
565
+ tokens=data.get("tokens_used"),
566
+ )
567
+ except Exception as exc:
568
+ logger.warning("Bus turn-handler failed: %s", exc)
569
+
570
+ await bus.subscribe("ConversationTurn", _on_turn_event)
571
+ logger.debug("EpisodicMemory subscribed to ConversationTurn events")
572
+
573
+
574
+ # ── Private helpers ───────────────────────────────────────────────────────────
575
+
576
+
577
+ def _row_to_turn(row: Any) -> Turn:
578
+ return Turn(
579
+ id=row["id"],
580
+ session_id=row["session_id"],
581
+ turn_index=row["turn_index"],
582
+ role=row["role"],
583
+ content=row["content"],
584
+ model_used=row["model_used"],
585
+ tokens_used=row["tokens_used"],
586
+ created_at=row["created_at"],
587
+ embedding_id=row["embedding_id"],
588
+ )