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/__init__.py +3 -0
- curu_memory/__main__.py +1032 -0
- curu_memory/access.py +562 -0
- curu_memory/audit.py +63 -0
- curu_memory/auth.py +41 -0
- curu_memory/browser.py +396 -0
- curu_memory/config.py +308 -0
- curu_memory/embedders.py +118 -0
- curu_memory/errors.py +96 -0
- curu_memory/indexing.py +440 -0
- curu_memory/media.py +199 -0
- curu_memory/oidc.py +437 -0
- curu_memory/okf.py +271 -0
- curu_memory/ports.py +344 -0
- curu_memory/render.py +61 -0
- curu_memory/retrieval.py +570 -0
- curu_memory/server.py +272 -0
- curu_memory/store_sqlite.py +551 -0
- curu_memory-0.2.0.dist-info/METADATA +723 -0
- curu_memory-0.2.0.dist-info/RECORD +23 -0
- curu_memory-0.2.0.dist-info/WHEEL +4 -0
- curu_memory-0.2.0.dist-info/entry_points.txt +2 -0
- curu_memory-0.2.0.dist-info/licenses/LICENSE +21 -0
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
|
curu_memory/embedders.py
ADDED
|
@@ -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))])
|