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.
- velune/__init__.py +5 -0
- velune/__main__.py +6 -0
- velune/cli/__init__.py +5 -0
- velune/cli/app.py +208 -0
- velune/cli/autocomplete.py +80 -0
- velune/cli/banner.py +60 -0
- velune/cli/commands/__init__.py +32 -0
- velune/cli/commands/ask.py +175 -0
- velune/cli/commands/base.py +16 -0
- velune/cli/commands/chat.py +228 -0
- velune/cli/commands/config.py +224 -0
- velune/cli/commands/daemon.py +88 -0
- velune/cli/commands/doctor.py +721 -0
- velune/cli/commands/init.py +170 -0
- velune/cli/commands/mcp.py +82 -0
- velune/cli/commands/memory.py +293 -0
- velune/cli/commands/models.py +683 -0
- velune/cli/commands/preflight.py +95 -0
- velune/cli/commands/run.py +270 -0
- velune/cli/commands/setup.py +184 -0
- velune/cli/commands/workspace.py +249 -0
- velune/cli/context.py +36 -0
- velune/cli/councilmodel_ui.py +199 -0
- velune/cli/display/council_view.py +254 -0
- velune/cli/display/memory_view.py +126 -0
- velune/cli/display/panels.py +35 -0
- velune/cli/display/progress.py +25 -0
- velune/cli/display/themes.py +25 -0
- velune/cli/main.py +15 -0
- velune/cli/model_selector.py +51 -0
- velune/cli/modes.py +86 -0
- velune/cli/pull_ui.py +123 -0
- velune/cli/registry.py +80 -0
- velune/cli/rendering/__init__.py +5 -0
- velune/cli/rendering/error_panel.py +79 -0
- velune/cli/rendering/markdown.py +63 -0
- velune/cli/repl.py +1855 -0
- velune/cli/session_manager.py +71 -0
- velune/cli/slash_commands.py +37 -0
- velune/cli/theme.py +8 -0
- velune/cognition/__init__.py +23 -0
- velune/cognition/agents/__init__.py +7 -0
- velune/cognition/agents/coder.py +209 -0
- velune/cognition/agents/planner.py +156 -0
- velune/cognition/agents/reviewer.py +195 -0
- velune/cognition/arbitrator.py +220 -0
- velune/cognition/architecture.py +415 -0
- velune/cognition/budget.py +65 -0
- velune/cognition/council/__init__.py +47 -0
- velune/cognition/council/base.py +217 -0
- velune/cognition/council/challenger.py +74 -0
- velune/cognition/council/coder.py +79 -0
- velune/cognition/council/critic_agent.py +43 -0
- velune/cognition/council/critic_configs.py +111 -0
- velune/cognition/council/critics.py +41 -0
- velune/cognition/council/debate.py +46 -0
- velune/cognition/council/factory.py +140 -0
- velune/cognition/council/messages.py +56 -0
- velune/cognition/council/planner.py +124 -0
- velune/cognition/council/reviewer.py +74 -0
- velune/cognition/council/synthesizer.py +67 -0
- velune/cognition/council/tiers.py +188 -0
- velune/cognition/council_orchestrator.py +282 -0
- velune/cognition/firewall.py +354 -0
- velune/cognition/module.py +46 -0
- velune/cognition/orchestrator.py +1205 -0
- velune/cognition/personality.py +238 -0
- velune/cognition/state.py +104 -0
- velune/cognition/style_resolver.py +64 -0
- velune/cognition/verification.py +205 -0
- velune/context/__init__.py +28 -0
- velune/context/assembler.py +240 -0
- velune/context/budget.py +97 -0
- velune/context/extractive.py +95 -0
- velune/context/prompt_adaptation.py +480 -0
- velune/context/sections.py +99 -0
- velune/context/token_counter.py +134 -0
- velune/context/utilization.py +33 -0
- velune/context/window.py +63 -0
- velune/core/__init__.py +89 -0
- velune/core/background.py +5 -0
- velune/core/config/__init__.py +37 -0
- velune/core/errors/__init__.py +90 -0
- velune/core/errors/catalog.py +188 -0
- velune/core/errors/execution.py +31 -0
- velune/core/errors/memory.py +25 -0
- velune/core/errors/orchestration.py +31 -0
- velune/core/errors/provider.py +37 -0
- velune/core/event_loop.py +35 -0
- velune/core/logging.py +83 -0
- velune/core/paths.py +165 -0
- velune/core/runtime.py +113 -0
- velune/core/startup_profiler.py +56 -0
- velune/core/task_registry.py +117 -0
- velune/core/trace.py +83 -0
- velune/core/types/__init__.py +48 -0
- velune/core/types/agent.py +53 -0
- velune/core/types/context.py +42 -0
- velune/core/types/inference.py +38 -0
- velune/core/types/memory.py +42 -0
- velune/core/types/model.py +70 -0
- velune/core/types/provider.py +62 -0
- velune/core/types/repository.py +38 -0
- velune/core/types/task.py +61 -0
- velune/core/types/workspace.py +28 -0
- velune/daemon/client.py +13 -0
- velune/daemon/server.py +127 -0
- velune/daemon/transport.py +179 -0
- velune/events.py +204 -0
- velune/execution/__init__.py +22 -0
- velune/execution/benchmarker.py +315 -0
- velune/execution/cancellation.py +53 -0
- velune/execution/checkpointer.py +130 -0
- velune/execution/command_spec.py +165 -0
- velune/execution/diff_preview.py +197 -0
- velune/execution/executor.py +181 -0
- velune/execution/module.py +18 -0
- velune/execution/multi_diff.py +67 -0
- velune/execution/path_guard.py +74 -0
- velune/execution/planner.py +91 -0
- velune/execution/rollback.py +89 -0
- velune/execution/sandbox.py +268 -0
- velune/execution/validator.py +115 -0
- velune/hardware/__init__.py +1 -0
- velune/hardware/detector.py +192 -0
- velune/kernel/__init__.py +55 -0
- velune/kernel/bootstrap.py +125 -0
- velune/kernel/config.py +426 -0
- velune/kernel/entrypoint.py +78 -0
- velune/kernel/health.py +54 -0
- velune/kernel/lifecycle.py +143 -0
- velune/kernel/module.py +17 -0
- velune/kernel/modules.py +23 -0
- velune/kernel/registry.py +96 -0
- velune/kernel/schemas.py +28 -0
- velune/main.py +9 -0
- velune/mcp/__init__.py +9 -0
- velune/mcp/client.py +115 -0
- velune/mcp/config.py +19 -0
- velune/mcp/server.py +624 -0
- velune/memory/__init__.py +32 -0
- velune/memory/compaction.py +506 -0
- velune/memory/embedding_pipeline.py +241 -0
- velune/memory/lifecycle.py +680 -0
- velune/memory/module.py +218 -0
- velune/memory/prioritizer.py +67 -0
- velune/memory/storage/episodic_schema.sql +53 -0
- velune/memory/storage/lancedb_store.py +282 -0
- velune/memory/storage/sqlite_manager.py +369 -0
- velune/memory/storage/sqlite_pool.py +149 -0
- velune/memory/tiers/episodic.py +588 -0
- velune/memory/tiers/graph.py +378 -0
- velune/memory/tiers/lineage.py +416 -0
- velune/memory/tiers/semantic.py +475 -0
- velune/memory/tiers/working.py +168 -0
- velune/memory/vitality.py +132 -0
- velune/models/__init__.py +15 -0
- velune/models/family.py +76 -0
- velune/models/module.py +20 -0
- velune/models/probes.py +192 -0
- velune/models/profile_cache.py +84 -0
- velune/models/profiler.py +108 -0
- velune/models/registry.py +251 -0
- velune/models/scorer.py +233 -0
- velune/models/specializations.py +205 -0
- velune/orchestration/__init__.py +19 -0
- velune/orchestration/engine.py +239 -0
- velune/orchestration/module.py +15 -0
- velune/orchestration/role_assignments.py +82 -0
- velune/orchestration/schemas.py +98 -0
- velune/plugins/__init__.py +20 -0
- velune/plugins/hooks.py +50 -0
- velune/plugins/loader.py +161 -0
- velune/plugins/registry.py +56 -0
- velune/plugins/schemas.py +21 -0
- velune/providers/__init__.py +23 -0
- velune/providers/adapters/anthropic.py +257 -0
- velune/providers/adapters/fireworks.py +115 -0
- velune/providers/adapters/google.py +234 -0
- velune/providers/adapters/groq.py +151 -0
- velune/providers/adapters/huggingface.py +210 -0
- velune/providers/adapters/llamacpp.py +208 -0
- velune/providers/adapters/lmstudio.py +175 -0
- velune/providers/adapters/ollama.py +233 -0
- velune/providers/adapters/openai.py +213 -0
- velune/providers/adapters/openrouter.py +81 -0
- velune/providers/adapters/together.py +134 -0
- velune/providers/adapters/xai.py +60 -0
- velune/providers/base.py +86 -0
- velune/providers/benchmarker.py +138 -0
- velune/providers/discovery/__init__.py +33 -0
- velune/providers/discovery/anthropic.py +79 -0
- velune/providers/discovery/benchmarks.py +44 -0
- velune/providers/discovery/classifier.py +69 -0
- velune/providers/discovery/fireworks.py +95 -0
- velune/providers/discovery/gguf.py +88 -0
- velune/providers/discovery/google.py +95 -0
- velune/providers/discovery/gpu.py +117 -0
- velune/providers/discovery/groq.py +21 -0
- velune/providers/discovery/huggingface.py +67 -0
- velune/providers/discovery/lmstudio.py +80 -0
- velune/providers/discovery/ollama.py +162 -0
- velune/providers/discovery/openai.py +96 -0
- velune/providers/discovery/openrouter.py +113 -0
- velune/providers/discovery/scanner.py +115 -0
- velune/providers/discovery/together.py +114 -0
- velune/providers/discovery/xai.py +57 -0
- velune/providers/health.py +67 -0
- velune/providers/health_monitor.py +169 -0
- velune/providers/keystore.py +142 -0
- velune/providers/local_paths.py +49 -0
- velune/providers/local_resolver.py +229 -0
- velune/providers/module.py +51 -0
- velune/providers/ollama_manager.py +193 -0
- velune/providers/registry.py +220 -0
- velune/providers/router.py +255 -0
- velune/providers/task_classifier.py +288 -0
- velune/py.typed +0 -0
- velune/repository/__init__.py +33 -0
- velune/repository/analyzer.py +127 -0
- velune/repository/ast_parser.py +822 -0
- velune/repository/blast_radius.py +298 -0
- velune/repository/boundary_classifier.py +295 -0
- velune/repository/cognition.py +316 -0
- velune/repository/grapher.py +179 -0
- velune/repository/import_graph.py +263 -0
- velune/repository/incremental_indexer.py +275 -0
- velune/repository/index_state.py +96 -0
- velune/repository/indexer.py +243 -0
- velune/repository/module.py +17 -0
- velune/repository/parser.py +474 -0
- velune/repository/project_type.py +300 -0
- velune/repository/rename_journal.py +287 -0
- velune/repository/scanner.py +193 -0
- velune/repository/schemas.py +102 -0
- velune/repository/symbol_registry.py +365 -0
- velune/repository/tracker.py +252 -0
- velune/retrieval/__init__.py +27 -0
- velune/retrieval/cache.py +110 -0
- velune/retrieval/fast_path.py +391 -0
- velune/retrieval/graph.py +124 -0
- velune/retrieval/hybrid.py +271 -0
- velune/retrieval/keyword.py +131 -0
- velune/retrieval/module.py +26 -0
- velune/retrieval/pipeline.py +303 -0
- velune/retrieval/reranker.py +102 -0
- velune/retrieval/schemas.py +59 -0
- velune/retrieval/slow_path.py +364 -0
- velune/retrieval/vector.py +203 -0
- velune/telemetry/__init__.py +59 -0
- velune/telemetry/cognition.py +267 -0
- velune/telemetry/cost_estimator.py +92 -0
- velune/telemetry/debug.py +304 -0
- velune/telemetry/doctor.py +244 -0
- velune/telemetry/logging.py +286 -0
- velune/telemetry/spans.py +277 -0
- velune/telemetry/token_tracker.py +140 -0
- velune/telemetry/usage_tracker.py +340 -0
- velune/tools/__init__.py +41 -0
- velune/tools/base/registry.py +87 -0
- velune/tools/base/tool.py +63 -0
- velune/tools/code/navigate.py +116 -0
- velune/tools/code/search.py +123 -0
- velune/tools/filesystem/read.py +75 -0
- velune/tools/filesystem/search.py +136 -0
- velune/tools/filesystem/write.py +163 -0
- velune/tools/git/history.py +177 -0
- velune/tools/git/operations.py +122 -0
- velune/tools/git/state.py +121 -0
- velune/tools/module.py +81 -0
- velune/tools/terminal/execute.py +72 -0
- velune/tools/terminal/history.py +47 -0
- velune/tools/web/fetch.py +55 -0
- velune/tools/web/validator.py +122 -0
- velune_cli-0.9.0.dist-info/METADATA +518 -0
- velune_cli-0.9.0.dist-info/RECORD +279 -0
- velune_cli-0.9.0.dist-info/WHEEL +4 -0
- velune_cli-0.9.0.dist-info/entry_points.txt +2 -0
- 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
|
+
)
|