curu-memory 0.2.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.
curu_memory/config.py ADDED
@@ -0,0 +1,308 @@
1
+ """The settings of curu-memory, read from the environment (contracts/cli.md)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from collections.abc import Callable, Mapping
7
+ from dataclasses import dataclass, field
8
+ from pathlib import Path
9
+ from urllib.parse import urlsplit
10
+
11
+ from curu_memory.ports import (
12
+ DEFAULT_MIN_SIMILARITY,
13
+ Prompts,
14
+ min_audio_similarity_for,
15
+ min_audio_to_audio_similarity_for,
16
+ min_audio_to_text_similarity_for,
17
+ min_image_similarity_for,
18
+ min_image_to_image_similarity_for,
19
+ min_image_to_text_similarity_for,
20
+ min_similarity_for,
21
+ min_video_similarity_for,
22
+ min_video_to_text_similarity_for,
23
+ min_video_to_video_similarity_for,
24
+ parse_space,
25
+ prompts_for,
26
+ )
27
+
28
+ DEFAULT_EMBEDDER_TIMEOUT = 5.0
29
+ #: The embedding model when CURU_MEMORY_EMBEDDER_URL is set with no model (ADR-003).
30
+ DEFAULT_EMBEDDER_MODEL = "google/embeddinggemma-2"
31
+ PREFIX = "CURU_MEMORY_"
32
+
33
+
34
+ class ConfigError(ValueError):
35
+ """A setting is wrong. The message names the variable and the fix."""
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class Settings:
40
+ data_dir: Path
41
+ concepts_dir: Path | None = None
42
+ embedder_url: str | None = None
43
+ embedder_model: str | None = None
44
+ embedder_timeout: float = DEFAULT_EMBEDDER_TIMEOUT
45
+ query_prompt: str | None = None
46
+ document_prompt: str | None = None
47
+ min_similarity_override: float | None = None
48
+ min_image_similarity_override: float | None = None
49
+ min_image_to_image_similarity_override: float | None = None
50
+ min_audio_similarity_override: float | None = None
51
+ min_video_similarity_override: float | None = None
52
+ min_video_to_video_similarity_override: float | None = None
53
+ min_video_to_text_similarity_override: float | None = None
54
+ min_audio_to_audio_similarity_override: float | None = None
55
+ min_audio_to_text_similarity_override: float | None = None
56
+ min_image_to_text_similarity_override: float | None = None
57
+ #: The handle of the person of serve, browse and search (ADR-016). None reads global only.
58
+ principal: str | None = None
59
+ #: The OIDC issuer of the HTTP transport. It turns on the OIDC mode (ADR-015).
60
+ oidc_issuer: str | None = None
61
+ #: The canonical URI of the server. None means the address of serve.
62
+ oidc_resource: str | None = None
63
+ #: The value that `aud` must hold. None means the resource (ADR-017).
64
+ oidc_audience: str | None = None
65
+ oidc_token_types: tuple[str, ...] = ("at+jwt",)
66
+ #: The client identifier of the Basic exchange. None turns the exchange off (ADR-018).
67
+ oidc_basic_client_id: str | None = None
68
+ # The tokens are secrets, so they never go into the repr (constitution, article IV).
69
+ embedder_token: str | None = field(default=None, repr=False)
70
+ http_token: str | None = field(default=None, repr=False)
71
+
72
+ @classmethod
73
+ def from_env(cls, env: Mapping[str, str] | None = None) -> Settings:
74
+ env = os.environ if env is None else env
75
+
76
+ def get(name: str) -> str | None:
77
+ value = env.get(PREFIX + name, "").strip()
78
+ return value or None
79
+
80
+ url = get("EMBEDDER_URL")
81
+ model = get("EMBEDDER_MODEL")
82
+ if model and not url:
83
+ raise ConfigError(
84
+ "CURU_MEMORY_EMBEDDER_MODEL needs CURU_MEMORY_EMBEDDER_URL, "
85
+ "the base URL of an OpenAI-compatible embedder."
86
+ )
87
+
88
+ concepts = get("CONCEPTS_DIR")
89
+ data = get("DATA_DIR")
90
+ return cls(
91
+ data_dir=Path(data) if data else _default_data_dir(env),
92
+ concepts_dir=Path(concepts) if concepts else None,
93
+ embedder_url=url.rstrip("/") if url else None,
94
+ embedder_model=model or (DEFAULT_EMBEDDER_MODEL if url else None),
95
+ embedder_timeout=_timeout(get("EMBEDDER_TIMEOUT")),
96
+ query_prompt=_prompt("EMBEDDER_QUERY_PROMPT", get("EMBEDDER_QUERY_PROMPT")),
97
+ document_prompt=_prompt("EMBEDDER_DOCUMENT_PROMPT", get("EMBEDDER_DOCUMENT_PROMPT")),
98
+ min_similarity_override=_similarity(
99
+ "EMBEDDER_MIN_SIMILARITY", get("EMBEDDER_MIN_SIMILARITY")
100
+ ),
101
+ min_image_similarity_override=_similarity(
102
+ "EMBEDDER_MIN_IMAGE_SIMILARITY", get("EMBEDDER_MIN_IMAGE_SIMILARITY")
103
+ ),
104
+ min_audio_similarity_override=_similarity(
105
+ "EMBEDDER_MIN_AUDIO_SIMILARITY", get("EMBEDDER_MIN_AUDIO_SIMILARITY")
106
+ ),
107
+ min_video_similarity_override=_similarity(
108
+ "EMBEDDER_MIN_VIDEO_SIMILARITY", get("EMBEDDER_MIN_VIDEO_SIMILARITY")
109
+ ),
110
+ min_video_to_video_similarity_override=_similarity(
111
+ "EMBEDDER_MIN_VIDEO_TO_VIDEO_SIMILARITY",
112
+ get("EMBEDDER_MIN_VIDEO_TO_VIDEO_SIMILARITY"),
113
+ ),
114
+ min_video_to_text_similarity_override=_similarity(
115
+ "EMBEDDER_MIN_VIDEO_TO_TEXT_SIMILARITY",
116
+ get("EMBEDDER_MIN_VIDEO_TO_TEXT_SIMILARITY"),
117
+ ),
118
+ min_audio_to_audio_similarity_override=_similarity(
119
+ "EMBEDDER_MIN_AUDIO_TO_AUDIO_SIMILARITY",
120
+ get("EMBEDDER_MIN_AUDIO_TO_AUDIO_SIMILARITY"),
121
+ ),
122
+ min_audio_to_text_similarity_override=_similarity(
123
+ "EMBEDDER_MIN_AUDIO_TO_TEXT_SIMILARITY",
124
+ get("EMBEDDER_MIN_AUDIO_TO_TEXT_SIMILARITY"),
125
+ ),
126
+ min_image_to_image_similarity_override=_similarity(
127
+ "EMBEDDER_MIN_IMAGE_TO_IMAGE_SIMILARITY",
128
+ get("EMBEDDER_MIN_IMAGE_TO_IMAGE_SIMILARITY"),
129
+ ),
130
+ min_image_to_text_similarity_override=_similarity(
131
+ "EMBEDDER_MIN_IMAGE_TO_TEXT_SIMILARITY",
132
+ get("EMBEDDER_MIN_IMAGE_TO_TEXT_SIMILARITY"),
133
+ ),
134
+ principal=_handle("PRINCIPAL", get("PRINCIPAL")),
135
+ oidc_issuer=_url("OIDC_ISSUER", get("OIDC_ISSUER"), issuer=True),
136
+ oidc_resource=_url("OIDC_RESOURCE", get("OIDC_RESOURCE"), issuer=False),
137
+ oidc_audience=get("OIDC_AUDIENCE"),
138
+ oidc_token_types=_token_types(get("OIDC_TOKEN_TYPES")),
139
+ oidc_basic_client_id=get("OIDC_BASIC_CLIENT_ID"),
140
+ embedder_token=get("EMBEDDER_TOKEN"),
141
+ http_token=get("HTTP_TOKEN"),
142
+ )
143
+
144
+ def prompts(self) -> Prompts:
145
+ """The task prompts of the embedder: the overrides, or the defaults of the model."""
146
+ defaults = prompts_for(self.embedder_model) if self.embedder_model else Prompts()
147
+ return Prompts(
148
+ query=self.query_prompt or defaults.query,
149
+ document=self.document_prompt or defaults.document,
150
+ )
151
+
152
+ def min_similarity(self) -> float:
153
+ """The similarity limit: the override, or the default of the model (ADR-005)."""
154
+ if self.min_similarity_override is not None:
155
+ return self.min_similarity_override
156
+ if self.embedder_model is None:
157
+ return DEFAULT_MIN_SIMILARITY
158
+ return min_similarity_for(self.embedder_model)
159
+
160
+ def min_image_similarity(self) -> float:
161
+ """The similarity limit of an image unit: the override, or the default of the model."""
162
+ if self.min_image_similarity_override is not None:
163
+ return self.min_image_similarity_override
164
+ if self.embedder_model is None:
165
+ return DEFAULT_MIN_SIMILARITY
166
+ return min_image_similarity_for(self.embedder_model)
167
+
168
+ def min_audio_similarity(self) -> float:
169
+ """The limit when a question in words meets an audio unit (ADR-011)."""
170
+ return self._limit(self.min_audio_similarity_override, min_audio_similarity_for)
171
+
172
+ def min_video_similarity(self) -> float:
173
+ """The limit when a question in words meets a video unit (ADR-013)."""
174
+ return self._limit(self.min_video_similarity_override, min_video_similarity_for)
175
+
176
+ def min_video_to_video_similarity(self) -> float:
177
+ """The limit when a video question meets a video unit (ADR-014)."""
178
+ return self._limit(
179
+ self.min_video_to_video_similarity_override, min_video_to_video_similarity_for
180
+ )
181
+
182
+ def min_video_to_text_similarity(self) -> float:
183
+ """The limit when a video question meets a text unit (ADR-014)."""
184
+ return self._limit(
185
+ self.min_video_to_text_similarity_override, min_video_to_text_similarity_for
186
+ )
187
+
188
+ def min_audio_to_audio_similarity(self) -> float:
189
+ """The limit when a clip question meets an audio unit (ADR-012)."""
190
+ return self._limit(
191
+ self.min_audio_to_audio_similarity_override, min_audio_to_audio_similarity_for
192
+ )
193
+
194
+ def min_audio_to_text_similarity(self) -> float:
195
+ """The limit when a clip question meets a text unit (ADR-012)."""
196
+ return self._limit(
197
+ self.min_audio_to_text_similarity_override, min_audio_to_text_similarity_for
198
+ )
199
+
200
+ def min_image_to_image_similarity(self) -> float:
201
+ """The limit when an image question meets an image unit (ADR-010)."""
202
+ return self._limit(
203
+ self.min_image_to_image_similarity_override, min_image_to_image_similarity_for
204
+ )
205
+
206
+ def min_image_to_text_similarity(self) -> float:
207
+ """The limit when an image question meets a text unit (ADR-010)."""
208
+ return self._limit(
209
+ self.min_image_to_text_similarity_override, min_image_to_text_similarity_for
210
+ )
211
+
212
+ def _limit(self, override: float | None, default_for: Callable[[str], float]) -> float:
213
+ if override is not None:
214
+ return override
215
+ if self.embedder_model is None:
216
+ return DEFAULT_MIN_SIMILARITY
217
+ return default_for(self.embedder_model)
218
+
219
+ def index_path(self, tier_space: str) -> Path:
220
+ """The index file of one tier space (ADR-001)."""
221
+ return self.data_dir / f"{tier_space}.sqlite"
222
+
223
+
224
+ _LOOPBACK = frozenset({"127.0.0.1", "localhost", "::1"})
225
+
226
+
227
+ def _url(name: str, value: str | None, *, issuer: bool) -> str | None:
228
+ """An absolute URL with no fragment. An issuer on the network must use HTTPS (R-5)."""
229
+ if value is None:
230
+ return None
231
+ parts = urlsplit(value)
232
+ if parts.scheme not in ("http", "https") or not parts.hostname or parts.fragment:
233
+ raise ConfigError(
234
+ f"{PREFIX}{name} must be an absolute http or https URL with no fragment. "
235
+ f"It is {value!r}."
236
+ )
237
+ if issuer and parts.scheme == "http" and parts.hostname not in _LOOPBACK:
238
+ raise ConfigError(
239
+ f"{PREFIX}{name} must use https. Plain http is only for an issuer on "
240
+ f"127.0.0.1, ::1 or localhost. It is {value!r}."
241
+ )
242
+ return value
243
+
244
+
245
+ def _token_types(value: str | None) -> tuple[str, ...]:
246
+ if value is None:
247
+ return ("at+jwt",)
248
+ types = tuple(part.strip() for part in value.split(",") if part.strip())
249
+ if not types:
250
+ raise ConfigError(
251
+ f"{PREFIX}OIDC_TOKEN_TYPES must list one or more token types, for example at+jwt,JWT."
252
+ )
253
+ return types
254
+
255
+
256
+ def _handle(name: str, value: str | None) -> str | None:
257
+ """A handle: 1 to 40 characters from a to z, 0 to 9 and -, first a letter (ADR-016)."""
258
+ if value is None:
259
+ return None
260
+ try:
261
+ parse_space(f"personal/{value}")
262
+ except ValueError:
263
+ raise ConfigError(
264
+ f"{PREFIX}{name} must be the handle of a registered person: 1 to 40 characters "
265
+ f"from a to z, 0 to 9 and -, first a letter. It is {value!r}."
266
+ ) from None
267
+ return value
268
+
269
+
270
+ def _default_data_dir(env: Mapping[str, str]) -> Path:
271
+ xdg = env.get("XDG_DATA_HOME", "").strip()
272
+ if xdg:
273
+ return Path(xdg) / "curu-memory"
274
+ home = env.get("HOME", "").strip()
275
+ base = Path(home) if home else Path.home()
276
+ return base / ".local" / "share" / "curu-memory"
277
+
278
+
279
+ def _prompt(name: str, value: str | None) -> str | None:
280
+ if value is not None and "{text}" not in value:
281
+ raise ConfigError(f"{PREFIX}{name} must contain {{text}}, the place of the text.")
282
+ return value
283
+
284
+
285
+ def _similarity(name: str, value: str | None) -> float | None:
286
+ if value is None:
287
+ return None
288
+ try:
289
+ limit = float(value)
290
+ except ValueError:
291
+ limit = 2.0
292
+ if not -1.0 <= limit <= 1.0:
293
+ raise ConfigError(f"{PREFIX}{name} must be a number from -1 to 1, not {value!r}.")
294
+ return limit
295
+
296
+
297
+ def _timeout(value: str | None) -> float:
298
+ if value is None:
299
+ return DEFAULT_EMBEDDER_TIMEOUT
300
+ try:
301
+ seconds = float(value)
302
+ except ValueError:
303
+ seconds = 0.0
304
+ if seconds <= 0:
305
+ raise ConfigError(
306
+ f"CURU_MEMORY_EMBEDDER_TIMEOUT must be a number of seconds above 0, not {value!r}."
307
+ )
308
+ return seconds
@@ -0,0 +1,118 @@
1
+ """Adapters of the `Embedder` port. None of them loads model weights (ADR-001)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import math
7
+ import struct
8
+ from collections.abc import Sequence
9
+
10
+ import httpx2
11
+
12
+ from curu_memory.media import MediaFile
13
+
14
+
15
+ class FakeEmbedder:
16
+ """A deterministic vector from the SHA-256 of the text. It has no meaning.
17
+
18
+ The default test tier uses it, so that the tests need no model (constitution,
19
+ article VI). It proves the plumbing of the match of meaning, not its quality.
20
+ """
21
+
22
+ def __init__(self, dimension: int = 64) -> None:
23
+ self._dimension = dimension
24
+
25
+ @property
26
+ def model(self) -> str:
27
+ return f"fake-sha256-{self._dimension}"
28
+
29
+ @property
30
+ def dimension(self) -> int:
31
+ return self._dimension
32
+
33
+ def embed(self, texts: Sequence[str]) -> list[list[float]]:
34
+ return [self._vector(text) for text in texts]
35
+
36
+ def embed_media(self, files: Sequence[MediaFile]) -> list[list[float]]:
37
+ return [self._vector(f"{media.kind}:{media.sha256}") for media in files]
38
+
39
+ def _vector(self, text: str) -> list[float]:
40
+ values: list[float] = []
41
+ counter = 0
42
+ while len(values) < self._dimension:
43
+ digest = hashlib.sha256(f"{counter}:{text}".encode()).digest()
44
+ for (number,) in struct.iter_unpack(">I", digest):
45
+ values.append(number / 2**32 - 0.5)
46
+ counter += 1
47
+ values = values[: self._dimension]
48
+ norm = math.sqrt(sum(v * v for v in values)) or 1.0
49
+ return [v / norm for v in values]
50
+
51
+
52
+ class OpenAICompatibleEmbedder:
53
+ """Calls `POST /v1/embeddings` of llama-server, Ollama, vLLM or another server.
54
+
55
+ The server holds the model. curu-memory sends only the texts and the model name.
56
+ The token never goes into a log or the repr (constitution, article IV).
57
+ """
58
+
59
+ def __init__(
60
+ self, base_url: str, model: str, *, token: str | None = None, timeout: float = 5.0
61
+ ) -> None:
62
+ base = base_url.rstrip("/")
63
+ self._url = f"{base}/embeddings" if base.endswith("/v1") else f"{base}/v1/embeddings"
64
+ self._model = model
65
+ self._token = token
66
+ self._timeout = timeout
67
+ self._dimension: int | None = None
68
+
69
+ def __repr__(self) -> str:
70
+ return f"OpenAICompatibleEmbedder(url={self._url!r}, model={self._model!r})"
71
+
72
+ @property
73
+ def model(self) -> str:
74
+ return self._model
75
+
76
+ @property
77
+ def dimension(self) -> int | None:
78
+ return self._dimension
79
+
80
+ def embed(self, texts: Sequence[str]) -> list[list[float]]:
81
+ return self._post(list(texts))
82
+
83
+ def embed_media(self, files: Sequence[MediaFile]) -> list[list[float]]:
84
+ """Send each file as an item `{"<kind>": "data:..."}`, as SGLang takes it.
85
+
86
+ The kind is `image` or `audio`, from the MIME type (ADR-007, ADR-011).
87
+ """
88
+ return self._post([{media.kind: media.data_uri()} for media in files])
89
+
90
+ def _post(self, items: list[str] | list[dict[str, str]]) -> list[list[float]]:
91
+ headers = {"Authorization": f"Bearer {self._token}"} if self._token else {}
92
+ try:
93
+ response = httpx2.post(
94
+ self._url,
95
+ json={"model": self._model, "input": items},
96
+ headers=headers,
97
+ timeout=self._timeout,
98
+ )
99
+ except httpx2.TimeoutException:
100
+ raise TimeoutError(
101
+ f"the embedder did not answer in {self._timeout:g} seconds"
102
+ ) from None
103
+ except httpx2.HTTPError as error:
104
+ raise RuntimeError(f"the embedder cannot be reached: {type(error).__name__}") from None
105
+ if response.status_code != 200:
106
+ raise RuntimeError(f"the embedder answered with HTTP {response.status_code}")
107
+ try:
108
+ data = sorted(response.json()["data"], key=lambda item: item["index"])
109
+ vectors = [[float(x) for x in item["embedding"]] for item in data]
110
+ except (ValueError, KeyError, TypeError):
111
+ raise RuntimeError(
112
+ "the embedder gave an answer that is not an embedding list"
113
+ ) from None
114
+ if len(vectors) != len(items):
115
+ raise RuntimeError(f"the embedder gave {len(vectors)} vectors for {len(items)} inputs")
116
+ if vectors:
117
+ self._dimension = len(vectors[0])
118
+ return vectors
curu_memory/errors.py ADDED
@@ -0,0 +1,96 @@
1
+ """Typed errors, and the one mapping from them to MCP tool results.
2
+
3
+ The application raises these errors and knows no MCP. The server calls
4
+ `tool_result_from` in one place, so that each failure reaches the agent as an error
5
+ result whose one text block is a JSON object with `code`, `message` and `details`.
6
+
7
+ The result is built here, not raised as a `ToolError`, because SDK 2.x puts
8
+ "Error executing tool <name>: " before each `ToolError` message.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ from typing import Any
15
+
16
+ from mcp_types import CallToolResult, TextContent
17
+
18
+
19
+ class CuruMemoryError(Exception):
20
+ """A failure that the caller can act on. Each subclass has one code."""
21
+
22
+ code = "error"
23
+
24
+ def __init__(self, message: str, details: dict[str, Any] | None = None) -> None:
25
+ super().__init__(message)
26
+ self.message = message
27
+ self.details = details
28
+
29
+
30
+ class IndexMissing(CuruMemoryError):
31
+ code = "index_missing"
32
+
33
+ def __init__(self, tier_space: str) -> None:
34
+ super().__init__(
35
+ f"There is no index for the {tier_space} knowledge. "
36
+ "Ask the operator to run: curu-memory index",
37
+ {"tier_space": tier_space},
38
+ )
39
+
40
+
41
+ class ConceptNotFound(CuruMemoryError):
42
+ code = "concept_not_found"
43
+
44
+ def __init__(self, concept_id: str) -> None:
45
+ super().__init__(
46
+ f"No concept has the identifier {concept_id!r}. "
47
+ "Use an identifier from search_knowledge.",
48
+ {"id": concept_id},
49
+ )
50
+
51
+
52
+ class InvalidArgument(CuruMemoryError):
53
+ code = "invalid_argument"
54
+
55
+ def __init__(self, field: str, message: str) -> None:
56
+ super().__init__(message, {"field": field})
57
+
58
+
59
+ class EmbedderFailed(CuruMemoryError):
60
+ code = "embedder_failed"
61
+
62
+ def __init__(self, reason: str) -> None:
63
+ super().__init__(
64
+ f"The embedder failed: {reason}. The old index is unchanged. "
65
+ "Make sure that the embedder runs, then run the index command again.",
66
+ {"reason": reason},
67
+ )
68
+
69
+
70
+ class NoReadableSpace(CuruMemoryError):
71
+ """The caller can read no space, for example a person that the operator removed."""
72
+
73
+ code = "access_refused"
74
+
75
+ def __init__(self) -> None:
76
+ super().__init__(
77
+ "The caller can read no space. Ask the operator to register the person "
78
+ "or to give it a role.",
79
+ )
80
+
81
+
82
+ class IndexRefused(CuruMemoryError):
83
+ """The index command refuses a space, and no index changes."""
84
+
85
+ code = "index_refused"
86
+
87
+ def __init__(self, message: str) -> None:
88
+ super().__init__(message)
89
+
90
+
91
+ def tool_result_from(error: CuruMemoryError) -> CallToolResult:
92
+ """Convert an application error to the error result that the agent gets."""
93
+ body: dict[str, Any] = {"code": error.code, "message": error.message}
94
+ if error.details is not None:
95
+ body["details"] = error.details
96
+ return CallToolResult(is_error=True, content=[TextContent(text=json.dumps(body))])