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,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)
|