cpersona 2.4.34__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.
- cpersona/__init__.py +1 -0
- cpersona/__main__.py +6 -0
- cpersona/_vendored_mcp_common/__init__.py +64 -0
- cpersona/_vendored_mcp_common/embedding_client.py +183 -0
- cpersona/_vendored_mcp_common/isolation.py +113 -0
- cpersona/_vendored_mcp_common/llm_provider.py +1609 -0
- cpersona/_vendored_mcp_common/mcp_stream_interceptor.py +233 -0
- cpersona/_vendored_mcp_common/mcp_utils.py +180 -0
- cpersona/_vendored_mcp_common/mgp_utils.py +234 -0
- cpersona/_vendored_mcp_common/no_persist.py +185 -0
- cpersona/_vendored_mcp_common/search.py +257 -0
- cpersona/_vendored_mcp_common/semantic_cache.py +146 -0
- cpersona/_vendored_mcp_common/validation.py +65 -0
- cpersona/admin_handlers.py +1442 -0
- cpersona/config.py +126 -0
- cpersona/database.py +289 -0
- cpersona/health.py +205 -0
- cpersona/maintenance_handlers.py +634 -0
- cpersona/memory_handlers.py +1112 -0
- cpersona/proxy_stdio.py +122 -0
- cpersona/server.py +1019 -0
- cpersona/tasks.py +159 -0
- cpersona/utils.py +179 -0
- cpersona/vector.py +332 -0
- cpersona-2.4.34.dist-info/METADATA +13 -0
- cpersona-2.4.34.dist-info/RECORD +29 -0
- cpersona-2.4.34.dist-info/WHEEL +4 -0
- cpersona-2.4.34.dist-info/entry_points.txt +2 -0
- cpersona-2.4.34.dist-info/licenses/LICENSE +21 -0
cpersona/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""CPersona — persistent AI memory MCP server."""
|
cpersona/__main__.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Common utilities shared across Magic Gateway Protocol (MGP) Python servers.
|
|
2
|
+
|
|
3
|
+
Foundation layer (Phase 2 Step 2-α) ported from
|
|
4
|
+
``cloto-mcp-servers/servers/common/``:
|
|
5
|
+
|
|
6
|
+
- :mod:`mcp_common.validation` — graceful-degradation argument validators
|
|
7
|
+
(``validate_bool`` / ``validate_str`` / ``validate_int`` / ``validate_dict`` /
|
|
8
|
+
``validate_float`` / ``validate_list``).
|
|
9
|
+
- :mod:`mcp_common.isolation` — γ-semantics two-tier isolation helpers
|
|
10
|
+
shared by CPersona & CScheduler (``gamma_clause`` / ``coerce_for_*`` /
|
|
11
|
+
``extract_axes_for_*``).
|
|
12
|
+
- :mod:`mcp_common.no_persist` — session no-persist mode helpers
|
|
13
|
+
(``pause`` / ``resume`` / ``status`` / ``is_paused`` /
|
|
14
|
+
``make_skipped_response``).
|
|
15
|
+
- :mod:`mcp_common.mgp_utils` — MGP capability builder and stream-notification
|
|
16
|
+
emitters (``MgpCapabilities`` / ``send_mgp_stream_chunk`` /
|
|
17
|
+
``write_mgp_*``).
|
|
18
|
+
|
|
19
|
+
Network / cache layer (Phase 2 Step 2-β):
|
|
20
|
+
|
|
21
|
+
- :mod:`mcp_common.embedding_client` — vector embedding client with TTL-based
|
|
22
|
+
LRU cache for single-text queries (``EmbeddingClient`` /
|
|
23
|
+
``pack_embedding`` / ``unpack_embedding``).
|
|
24
|
+
- :mod:`mcp_common.semantic_cache` — in-memory semantic cache that matches
|
|
25
|
+
cached results via embedding similarity (``SemanticCache``).
|
|
26
|
+
- :mod:`mcp_common.search` — search provider abstraction with a SearXNG →
|
|
27
|
+
Tavily → DuckDuckGo fallback chain (``SearchProvider`` /
|
|
28
|
+
``SearXNGProvider`` / ``TavilyProvider`` / ``DuckDuckGoProvider`` /
|
|
29
|
+
``ChainProvider`` / ``create_search_provider``).
|
|
30
|
+
|
|
31
|
+
MCP SDK tooling (Phase 2 Step 2-γ):
|
|
32
|
+
|
|
33
|
+
- :mod:`mcp_common.mcp_utils` — decorator-based MCP tool registration
|
|
34
|
+
with auto-validated parameter extraction (``ToolRegistry`` /
|
|
35
|
+
``ToolRegistry.tool`` / ``ToolRegistry.auto_tool`` /
|
|
36
|
+
``run_mcp_server`` / ``install_mgp_validation_filter``).
|
|
37
|
+
|
|
38
|
+
MGP streaming middleware (Phase 2 Step 2-δ):
|
|
39
|
+
|
|
40
|
+
- :mod:`mcp_common.mcp_stream_interceptor` — async pump that sits between
|
|
41
|
+
the raw stdio read stream and the MCP ``ServerSession``, intercepting
|
|
42
|
+
the custom MGP §12.7 ``mgp/stream/cancel`` requests and §12.9
|
|
43
|
+
``notifications/mgp.stream.gap`` notifications that would otherwise be
|
|
44
|
+
dropped by the SDK's closed Pydantic unions (``mgp_message_interceptor``
|
|
45
|
+
/ ``_handle_mgp_cancel`` / ``_handle_mgp_gap``).
|
|
46
|
+
|
|
47
|
+
LLM provider + streaming (Phase 2 Step 2-ε):
|
|
48
|
+
|
|
49
|
+
- :mod:`mcp_common.llm_provider` — OpenAI-compatible HTTP client with
|
|
50
|
+
SSE streaming, MGP §12 stream support (``mgp/stream/cancel`` /
|
|
51
|
+
``notifications/mgp.stream.gap`` retransmission), tool-call orchestration,
|
|
52
|
+
and the ``create_llm_mcp_server`` / ``run_server`` entry points
|
|
53
|
+
(``ProviderConfig`` / ``StreamState`` / ``LlmApiError`` /
|
|
54
|
+
``call_llm_api_streaming`` / ``handle_think_with_tools`` /
|
|
55
|
+
``handle_think_with_tools_streaming`` / ``load_llm_provider_config``).
|
|
56
|
+
This module owns the ``StreamState`` dataclass referenced by the
|
|
57
|
+
``TYPE_CHECKING`` block in :mod:`mcp_common.mcp_stream_interceptor`;
|
|
58
|
+
the forward reference left in Step 2-δ now resolves cleanly.
|
|
59
|
+
|
|
60
|
+
Phase 2 module migration from ``cloto-mcp-servers/servers/common/`` is
|
|
61
|
+
complete with this sub-PR.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
__version__ = "0.5.1"
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""Shared embedding client for cloto-mcp-servers.
|
|
2
|
+
|
|
3
|
+
Extracted from cpersona/server.py:146-301 in CScheduler v0.2 to allow
|
|
4
|
+
multiple MCP servers (CPersona, CScheduler, ...) to share a single
|
|
5
|
+
embedding implementation while talking to the same embedding HTTP server
|
|
6
|
+
(default port 8401) or an OpenAI-compatible API.
|
|
7
|
+
|
|
8
|
+
Each server owns its own EmbeddingClient instance; configuration is
|
|
9
|
+
injected via constructor arguments — env-var reading is the caller's
|
|
10
|
+
responsibility so that BC fallbacks (e.g. CPERSONA_EMBEDDING_*) live in
|
|
11
|
+
the relevant server's startup code.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import hashlib
|
|
15
|
+
import logging
|
|
16
|
+
import struct
|
|
17
|
+
import time
|
|
18
|
+
from collections import OrderedDict
|
|
19
|
+
|
|
20
|
+
import httpx
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
DEFAULT_CACHE_SIZE = 256
|
|
25
|
+
DEFAULT_CACHE_TTL = 300 # seconds
|
|
26
|
+
DEFAULT_TIMEOUT_SECS = 30
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class EmbeddingClient:
|
|
30
|
+
"""Client for computing vector embeddings via HTTP or OpenAI-compatible API.
|
|
31
|
+
|
|
32
|
+
Includes a TTL-based LRU cache for single-text queries (recall dedup).
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
mode: str,
|
|
38
|
+
http_url: str = "",
|
|
39
|
+
api_key: str = "",
|
|
40
|
+
api_url: str = "",
|
|
41
|
+
model: str = "",
|
|
42
|
+
cache_size: int = DEFAULT_CACHE_SIZE,
|
|
43
|
+
cache_ttl: int = DEFAULT_CACHE_TTL,
|
|
44
|
+
timeout: int = DEFAULT_TIMEOUT_SECS,
|
|
45
|
+
):
|
|
46
|
+
self.mode = mode
|
|
47
|
+
self._http_url = http_url
|
|
48
|
+
self._api_key = api_key
|
|
49
|
+
self._api_url = api_url
|
|
50
|
+
self._model = model
|
|
51
|
+
self._client = None
|
|
52
|
+
# LRU cache: key=text_hash, value=(embedding, timestamp)
|
|
53
|
+
self._cache: OrderedDict[str, tuple[list[float], float]] = OrderedDict()
|
|
54
|
+
self._cache_size = cache_size
|
|
55
|
+
self._cache_ttl = cache_ttl
|
|
56
|
+
self._timeout = timeout
|
|
57
|
+
self.cache_hits = 0
|
|
58
|
+
self.cache_misses = 0
|
|
59
|
+
|
|
60
|
+
async def initialize(self):
|
|
61
|
+
"""Create persistent HTTP client."""
|
|
62
|
+
self._client = httpx.AsyncClient(timeout=self._timeout)
|
|
63
|
+
logger.info(
|
|
64
|
+
"EmbeddingClient initialized (mode=%s, cache=%d, ttl=%ds, timeout=%ds)",
|
|
65
|
+
self.mode,
|
|
66
|
+
self._cache_size,
|
|
67
|
+
self._cache_ttl,
|
|
68
|
+
self._timeout,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
async def close(self):
|
|
72
|
+
"""Close HTTP client."""
|
|
73
|
+
if self._client:
|
|
74
|
+
await self._client.aclose()
|
|
75
|
+
self._client = None
|
|
76
|
+
|
|
77
|
+
def _cache_key(self, text: str) -> str:
|
|
78
|
+
return hashlib.sha256(text.encode()).hexdigest()[:16]
|
|
79
|
+
|
|
80
|
+
def _cache_get(self, text: str) -> list[float] | None:
|
|
81
|
+
"""Look up a single text in cache. Returns embedding or None."""
|
|
82
|
+
key = self._cache_key(text)
|
|
83
|
+
entry = self._cache.get(key)
|
|
84
|
+
if entry is None:
|
|
85
|
+
return None
|
|
86
|
+
embedding, ts = entry
|
|
87
|
+
if time.monotonic() - ts > self._cache_ttl:
|
|
88
|
+
del self._cache[key]
|
|
89
|
+
return None
|
|
90
|
+
# Move to end (most recently used)
|
|
91
|
+
self._cache.move_to_end(key)
|
|
92
|
+
return embedding
|
|
93
|
+
|
|
94
|
+
def _cache_put(self, text: str, embedding: list[float]) -> None:
|
|
95
|
+
"""Store a single text→embedding in cache."""
|
|
96
|
+
key = self._cache_key(text)
|
|
97
|
+
self._cache[key] = (embedding, time.monotonic())
|
|
98
|
+
self._cache.move_to_end(key)
|
|
99
|
+
while len(self._cache) > self._cache_size:
|
|
100
|
+
self._cache.popitem(last=False)
|
|
101
|
+
|
|
102
|
+
async def embed(self, texts: list[str]) -> list[list[float]] | None:
|
|
103
|
+
"""Compute embeddings with LRU cache for single-text queries.
|
|
104
|
+
|
|
105
|
+
Cache is used only for single-text calls (the common recall path).
|
|
106
|
+
Batch calls bypass cache to avoid complexity.
|
|
107
|
+
"""
|
|
108
|
+
if self.mode == "none" or not self._client:
|
|
109
|
+
return None
|
|
110
|
+
|
|
111
|
+
# Single-text cache path
|
|
112
|
+
if len(texts) == 1:
|
|
113
|
+
cached = self._cache_get(texts[0])
|
|
114
|
+
if cached is not None:
|
|
115
|
+
self.cache_hits += 1
|
|
116
|
+
return [cached]
|
|
117
|
+
self.cache_misses += 1
|
|
118
|
+
|
|
119
|
+
try:
|
|
120
|
+
if self.mode == "http":
|
|
121
|
+
result = await self._embed_via_http(texts)
|
|
122
|
+
elif self.mode == "api":
|
|
123
|
+
result = await self._embed_via_api(texts)
|
|
124
|
+
else:
|
|
125
|
+
logger.warning("Unknown embedding mode: %s", self.mode)
|
|
126
|
+
return None
|
|
127
|
+
except (httpx.RequestError, httpx.HTTPStatusError, ValueError, KeyError) as e:
|
|
128
|
+
logger.warning("Embedding request failed: %s", e)
|
|
129
|
+
return None
|
|
130
|
+
|
|
131
|
+
# Cache single-text results
|
|
132
|
+
if result and len(texts) == 1 and len(result) == 1:
|
|
133
|
+
self._cache_put(texts[0], result[0])
|
|
134
|
+
|
|
135
|
+
return result
|
|
136
|
+
|
|
137
|
+
async def _embed_via_http(self, texts: list[str]) -> list[list[float]] | None:
|
|
138
|
+
"""Call the embedding server's HTTP endpoint."""
|
|
139
|
+
response = await self._client.post(
|
|
140
|
+
self._http_url,
|
|
141
|
+
json={"texts": texts},
|
|
142
|
+
)
|
|
143
|
+
response.raise_for_status()
|
|
144
|
+
data = response.json()
|
|
145
|
+
return data.get("embeddings")
|
|
146
|
+
|
|
147
|
+
async def _embed_via_api(self, texts: list[str]) -> list[list[float]] | None:
|
|
148
|
+
"""Call OpenAI-compatible embedding API directly."""
|
|
149
|
+
import numpy as np
|
|
150
|
+
|
|
151
|
+
response = await self._client.post(
|
|
152
|
+
self._api_url,
|
|
153
|
+
headers={
|
|
154
|
+
"Authorization": f"Bearer {self._api_key}",
|
|
155
|
+
"Content-Type": "application/json",
|
|
156
|
+
},
|
|
157
|
+
json={"model": self._model, "input": texts},
|
|
158
|
+
)
|
|
159
|
+
response.raise_for_status()
|
|
160
|
+
data = response.json()
|
|
161
|
+
embeddings = [item["embedding"] for item in data["data"]]
|
|
162
|
+
|
|
163
|
+
# L2-normalize for consistent cosine similarity via dot product
|
|
164
|
+
result = []
|
|
165
|
+
for emb in embeddings:
|
|
166
|
+
vec = np.array(emb, dtype=np.float32)
|
|
167
|
+
norm = np.linalg.norm(vec)
|
|
168
|
+
if norm > 1e-9:
|
|
169
|
+
vec = vec / norm
|
|
170
|
+
result.append(vec.tolist())
|
|
171
|
+
|
|
172
|
+
return result
|
|
173
|
+
|
|
174
|
+
@staticmethod
|
|
175
|
+
def pack_embedding(embedding: list[float]) -> bytes:
|
|
176
|
+
"""Pack a float list into a BLOB (little-endian float32)."""
|
|
177
|
+
return struct.pack(f"<{len(embedding)}f", *embedding)
|
|
178
|
+
|
|
179
|
+
@staticmethod
|
|
180
|
+
def unpack_embedding(blob: bytes) -> list[float]:
|
|
181
|
+
"""Unpack a BLOB into a float list."""
|
|
182
|
+
n = len(blob) // 4
|
|
183
|
+
return list(struct.unpack(f"<{n}f", blob))
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""γ-semantics two-tier isolation helpers shared by CPersona & CScheduler.
|
|
2
|
+
|
|
3
|
+
This module is the **single source of truth** for the per-axis isolation
|
|
4
|
+
filter (project_id, agent_id, …) used to give each MCP server a
|
|
5
|
+
two-bucket-aware view of its data:
|
|
6
|
+
|
|
7
|
+
- Write side: a missing / non-string / empty value collapses to ``''``,
|
|
8
|
+
which we treat as the *global pool* — rows visible from every bucket
|
|
9
|
+
read query.
|
|
10
|
+
- Read side: callers can pass
|
|
11
|
+
|
|
12
|
+
- ``None`` → no filter on this axis (return everything)
|
|
13
|
+
- ``''`` → match only the global pool (rows whose axis = ``''``)
|
|
14
|
+
- ``'X'`` → match the named bucket *plus* the global pool, i.e.
|
|
15
|
+
``axis IN ('X', '')`` — the eponymous "γ" union.
|
|
16
|
+
|
|
17
|
+
Each MCP server typically wires one or two axes (project_id alone for
|
|
18
|
+
CPersona, project_id × agent_id for CScheduler). The functions here are
|
|
19
|
+
axis-agnostic: pass any column name and any caller-supplied value, get
|
|
20
|
+
back a SQL fragment plus parameter list ready to splat into
|
|
21
|
+
``aiosqlite.Connection.execute``.
|
|
22
|
+
|
|
23
|
+
Pure-Python, stdlib-only. Database backend agnostic — emits ``IN (?, ?)``
|
|
24
|
+
or ``= ?`` placeholders that work with sqlite3, asyncpg, mysql, etc.
|
|
25
|
+
|
|
26
|
+
Versioning: introduced in cloto-mcp-cscheduler 0.2.4 / cloto-mcp-cpersona
|
|
27
|
+
2.4.18 as a refactor of the verbatim-duplicated logic from those two
|
|
28
|
+
servers' v0.2.3 / v2.4.17 patches.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
from typing import Any
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"gamma_clause",
|
|
37
|
+
"coerce_for_write",
|
|
38
|
+
"coerce_for_read",
|
|
39
|
+
"extract_axes_for_write",
|
|
40
|
+
"extract_axes_for_read",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def gamma_clause(column: str, value: str | None) -> tuple[str, list[Any]]:
|
|
45
|
+
"""Build a SQL fragment + params for γ semantics on `column`.
|
|
46
|
+
|
|
47
|
+
- ``value is None`` → ``('', [])`` so the caller can skip the filter
|
|
48
|
+
entirely (= "return everything across all buckets").
|
|
49
|
+
- ``value == ''`` → ``('column = ?', [''])`` — match only the
|
|
50
|
+
global pool. Collapsed to a bare ``=`` so we don't double-bind the
|
|
51
|
+
same string into ``IN``.
|
|
52
|
+
- any other string → ``('column IN (?, ?)', [value, ''])`` —
|
|
53
|
+
γ union of the named bucket and the global pool.
|
|
54
|
+
|
|
55
|
+
The column name is interpolated raw, so callers MUST pass a trusted
|
|
56
|
+
identifier (typed as ``project_id`` / ``t.agent_id`` etc., never
|
|
57
|
+
user-supplied input).
|
|
58
|
+
"""
|
|
59
|
+
if value is None:
|
|
60
|
+
return ("", [])
|
|
61
|
+
if value == "":
|
|
62
|
+
return (f"{column} = ?", [""])
|
|
63
|
+
return (f"{column} IN (?, ?)", [value, ""])
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def coerce_for_write(value: Any) -> str:
|
|
67
|
+
"""Coerce an isolation axis value for INSERT.
|
|
68
|
+
|
|
69
|
+
Strings are preserved verbatim (including ``''`` for the global
|
|
70
|
+
pool). Anything else (``None``, missing kwarg → ``None`` upstream,
|
|
71
|
+
ints, dicts, etc.) collapses to ``''``. This matches the "default
|
|
72
|
+
to global pool" semantic the schema column declares with
|
|
73
|
+
``DEFAULT ''``.
|
|
74
|
+
"""
|
|
75
|
+
if isinstance(value, str):
|
|
76
|
+
return value
|
|
77
|
+
return ""
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def coerce_for_read(value: Any) -> str | None:
|
|
81
|
+
"""Coerce an isolation axis value for γ-filtered SELECT.
|
|
82
|
+
|
|
83
|
+
Strings (including ``''``) are preserved verbatim — the caller
|
|
84
|
+
distinguishes "global pool only" (``''``) from "no filter"
|
|
85
|
+
(``None``). Anything else collapses to ``None``, matching the
|
|
86
|
+
"absent kwarg → no filter" intuition for read paths.
|
|
87
|
+
"""
|
|
88
|
+
if isinstance(value, str):
|
|
89
|
+
return value
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def extract_axes_for_write(arguments: dict, *axes: str) -> tuple[str, ...]:
|
|
94
|
+
"""Bulk extract + coerce multiple isolation axes from an MCP
|
|
95
|
+
arguments dict for write paths. Returns a tuple of strings (one per
|
|
96
|
+
axis name in `axes`), each defaulting to ``''`` when missing.
|
|
97
|
+
|
|
98
|
+
Example::
|
|
99
|
+
|
|
100
|
+
project_id, agent_id = extract_axes_for_write(
|
|
101
|
+
arguments, "project_id", "agent_id"
|
|
102
|
+
)
|
|
103
|
+
"""
|
|
104
|
+
return tuple(coerce_for_write(arguments.get(a)) for a in axes)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def extract_axes_for_read(arguments: dict, *axes: str) -> tuple[str | None, ...]:
|
|
108
|
+
"""Bulk extract + coerce multiple isolation axes from an MCP
|
|
109
|
+
arguments dict for read paths. Returns a tuple of ``Optional[str]``
|
|
110
|
+
(one per axis name), where ``None`` means "no filter on this axis"
|
|
111
|
+
and ``''`` means "global pool only" — see :func:`gamma_clause` for
|
|
112
|
+
the SQL semantics."""
|
|
113
|
+
return tuple(coerce_for_read(arguments.get(a)) for a in axes)
|