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 ADDED
@@ -0,0 +1 @@
1
+ """CPersona — persistent AI memory MCP server."""
cpersona/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Enable ``python -m cpersona``."""
2
+
3
+ from cpersona.server import run
4
+
5
+ if __name__ == "__main__":
6
+ run()
@@ -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)