mindtrail 0.1.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.
Files changed (39) hide show
  1. mindtrail/__init__.py +17 -0
  2. mindtrail/__main__.py +3 -0
  3. mindtrail/cli/__init__.py +0 -0
  4. mindtrail/cli/main.py +425 -0
  5. mindtrail/core/__init__.py +0 -0
  6. mindtrail/core/config.py +46 -0
  7. mindtrail/core/exceptions.py +27 -0
  8. mindtrail/core/models.py +127 -0
  9. mindtrail/core/project.py +84 -0
  10. mindtrail/core/text.py +52 -0
  11. mindtrail/embeddings/__init__.py +33 -0
  12. mindtrail/embeddings/base.py +38 -0
  13. mindtrail/embeddings/fastembed_provider.py +112 -0
  14. mindtrail/embeddings/hashing.py +67 -0
  15. mindtrail/embeddings/rerankers.py +92 -0
  16. mindtrail/evaluation/__init__.py +6 -0
  17. mindtrail/evaluation/data/retrieval_holdout_v1.json +85 -0
  18. mindtrail/evaluation/data/retrieval_holdout_v2.json +92 -0
  19. mindtrail/evaluation/data/retrieval_v1.json +165 -0
  20. mindtrail/evaluation/dataset.py +80 -0
  21. mindtrail/evaluation/locomo.py +235 -0
  22. mindtrail/evaluation/metrics.py +57 -0
  23. mindtrail/evaluation/runner.py +225 -0
  24. mindtrail/mcp/__init__.py +3 -0
  25. mindtrail/mcp/server.py +259 -0
  26. mindtrail/memory/__init__.py +0 -0
  27. mindtrail/memory/context.py +59 -0
  28. mindtrail/memory/retrieval.py +88 -0
  29. mindtrail/memory/safety.py +25 -0
  30. mindtrail/memory/service.py +493 -0
  31. mindtrail/py.typed +0 -0
  32. mindtrail/storage/__init__.py +0 -0
  33. mindtrail/storage/interfaces.py +68 -0
  34. mindtrail/storage/sqlite.py +348 -0
  35. mindtrail-0.1.0.dist-info/METADATA +233 -0
  36. mindtrail-0.1.0.dist-info/RECORD +39 -0
  37. mindtrail-0.1.0.dist-info/WHEEL +4 -0
  38. mindtrail-0.1.0.dist-info/entry_points.txt +2 -0
  39. mindtrail-0.1.0.dist-info/licenses/LICENSE +21 -0
mindtrail/__init__.py ADDED
@@ -0,0 +1,17 @@
1
+ """Mindtrail: persistent, searchable memory for AI agents."""
2
+
3
+ from mindtrail.core.config import MindtrailConfig
4
+ from mindtrail.core.models import MemoryContext, MemoryRecord, MemoryType, RememberResult, SearchHit
5
+ from mindtrail.memory.service import MemoryService
6
+
7
+ __version__ = "0.1.0"
8
+
9
+ __all__ = [
10
+ "MemoryContext",
11
+ "MemoryRecord",
12
+ "MemoryService",
13
+ "MemoryType",
14
+ "MindtrailConfig",
15
+ "RememberResult",
16
+ "SearchHit",
17
+ ]
mindtrail/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from mindtrail.cli.main import main
2
+
3
+ raise SystemExit(main())
File without changes
mindtrail/cli/main.py ADDED
@@ -0,0 +1,425 @@
1
+ """``mindtrail`` command line: run the MCP server, set up clients, inspect and manage memories.
2
+
3
+ stdout is reserved for the MCP protocol while ``serve`` runs; all logs go to stderr.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import argparse
9
+ import importlib.util
10
+ import json
11
+ import logging
12
+ import os
13
+ import sqlite3
14
+ import sys
15
+ import threading
16
+ from collections.abc import Callable, Sequence
17
+ from pathlib import Path
18
+
19
+ from mindtrail import __version__
20
+ from mindtrail.core.config import MindtrailConfig
21
+ from mindtrail.core.exceptions import MindtrailError
22
+ from mindtrail.core.models import MemoryRecord, SearchHit
23
+ from mindtrail.core.project import detect_project
24
+ from mindtrail.memory.service import MemoryService
25
+
26
+ CLIENTS = ("claude-code", "cursor", "vscode", "codex")
27
+
28
+
29
+ def _client_setup(client: str) -> str:
30
+ """Configuration snippet for connecting ``client`` to the local stdio server."""
31
+ command, args = "mindtrail", ["serve"]
32
+ if client == "claude-code":
33
+ return "claude mcp add mindtrail --scope user -- mindtrail serve"
34
+ if client == "cursor":
35
+ return "~/.cursor/mcp.json\n" + json.dumps(
36
+ {"mcpServers": {"mindtrail": {"command": command, "args": args}}}, indent=2
37
+ )
38
+ if client == "vscode":
39
+ return ".vscode/mcp.json\n" + json.dumps(
40
+ {"servers": {"mindtrail": {"type": "stdio", "command": command, "args": args}}},
41
+ indent=2,
42
+ )
43
+ if client == "codex":
44
+ return (
45
+ '~/.codex/config.toml\n[mcp_servers.mindtrail]\ncommand = "mindtrail"\nargs = ["serve"]'
46
+ )
47
+ raise ValueError(client)
48
+
49
+
50
+ def _open() -> tuple[MemoryService, MindtrailConfig]:
51
+ config = MindtrailConfig.from_env()
52
+ return MemoryService.from_config(config), config
53
+
54
+
55
+ def _project_space() -> str | None:
56
+ project = detect_project()
57
+ return project.space_id if project else None
58
+
59
+
60
+ def _print_hits(hits: Sequence[SearchHit], as_json: bool) -> None:
61
+ if as_json:
62
+ print(json.dumps([hit.model_dump(mode="json") for hit in hits], indent=2))
63
+ return
64
+ if not hits:
65
+ print("No relevant memories.")
66
+ for hit in hits:
67
+ memory = hit.memory
68
+ print(f"{hit.score:5.2f} {memory.content}")
69
+ print(f" id={memory.id} space={memory.space_id} type={memory.memory_type.value}")
70
+
71
+
72
+ def _print_records(records: Sequence[MemoryRecord], as_json: bool) -> None:
73
+ if as_json:
74
+ print(json.dumps([r.model_dump(mode="json") for r in records], indent=2))
75
+ return
76
+ if not records:
77
+ print("No memories.")
78
+ for record in records:
79
+ print(f"{record.updated_at:%Y-%m-%d} [{record.space_id}] {record.content}")
80
+ print(f" id={record.id}")
81
+
82
+
83
+ def _warm_up(service: MemoryService) -> None:
84
+ """Load models, then embed any memories stored under a different embedding model (for
85
+ example after an upgrade). Until that finishes, keyword search still finds them."""
86
+ log = logging.getLogger(__name__)
87
+ try:
88
+ service.warm_up()
89
+ if count := service.reindex_embeddings():
90
+ log.info("re-embedded %d memories with %s", count, service.embedding_model)
91
+ except Exception:
92
+ log.exception("model warm-up failed; will retry on first use")
93
+
94
+
95
+ # -- commands ------------------------------------------------------------------------------
96
+
97
+
98
+ def cmd_serve(args: argparse.Namespace) -> int:
99
+ from mindtrail.mcp.server import create_server
100
+
101
+ profile = args.tools or os.environ.get("MINDTRAIL_TOOLS", "core")
102
+ if profile not in ("core", "full"):
103
+ print(f"error: MINDTRAIL_TOOLS must be 'core' or 'full', got {profile!r}", file=sys.stderr)
104
+ return 2
105
+ service, _ = _open()
106
+ server = create_server(
107
+ service, project_space=_project_space(), profile="full" if profile == "full" else "core"
108
+ )
109
+ # Load (or, if `mindtrail init` was skipped, download) models while the client is still
110
+ # connecting, so the agent's first recall doesn't stall. Tool calls wait on the same lock.
111
+ threading.Thread(target=_warm_up, args=(service,), daemon=True).start()
112
+ try:
113
+ server.run("stdio")
114
+ finally:
115
+ service.close()
116
+ return 0
117
+
118
+
119
+ def cmd_init(args: argparse.Namespace) -> int:
120
+ service, config = _open()
121
+ try:
122
+ stats = service.stats()
123
+ model = service.embedding_model
124
+ if model.startswith("fastembed") or service.reranker_model:
125
+ print("Preparing models; the first run downloads them...")
126
+ service.warm_up()
127
+ reindexed = service.reindex_embeddings()
128
+ finally:
129
+ service.close()
130
+ print(f"Mindtrail is ready. Data: {config.db_path} ({stats['total']} memories)")
131
+ print(f"Embeddings: {model}" + (f" (indexed {reindexed} memories)" if reindexed else ""))
132
+ print(f"Reranker: {service.reranker_model or 'none'}")
133
+ if not model.startswith("fastembed"):
134
+ print('Tip: pipx install "mindtrail[semantic]" for recall by meaning, not just words.')
135
+ print()
136
+ for client in [args.client] if args.client else CLIENTS:
137
+ print(f"-- {client} " + "-" * (60 - len(client)))
138
+ print(_client_setup(client) + "\n")
139
+ print('Then ask your agent: "Remember that this project uses conventional commits."')
140
+ print('Open a new session and ask: "How should I write commit messages here?"')
141
+ return 0
142
+
143
+
144
+ def cmd_doctor(args: argparse.Namespace) -> int:
145
+ config = MindtrailConfig.from_env()
146
+ checks: list[tuple[str, bool, str]] = []
147
+
148
+ def check(name: str, fn: Callable[[], str]) -> None:
149
+ try:
150
+ checks.append((name, True, fn()))
151
+ except Exception as exc:
152
+ checks.append((name, False, f"{type(exc).__name__}: {exc}"))
153
+
154
+ def python_version() -> str:
155
+ return sys.version.split()[0]
156
+
157
+ def fts5() -> str:
158
+ conn = sqlite3.connect(":memory:")
159
+ conn.execute("CREATE VIRTUAL TABLE t USING fts5(x)")
160
+ conn.close()
161
+ return f"SQLite {sqlite3.sqlite_version} with FTS5"
162
+
163
+ def database() -> str:
164
+ service = MemoryService.from_config(config)
165
+ try:
166
+ stats = service.stats()
167
+ return f"{config.db_path} ({stats['total']} memories, {stats['active']} active)"
168
+ finally:
169
+ service.close()
170
+
171
+ def embedder() -> str:
172
+ service = MemoryService.from_config(config)
173
+ try:
174
+ service.search("doctor check")
175
+ model = service.embedding_model
176
+ finally:
177
+ service.close()
178
+ hint = (
179
+ ""
180
+ if model.startswith("fastembed")
181
+ else " (install mindtrail[semantic] for better recall)"
182
+ )
183
+ return model + hint
184
+
185
+ def mcp_sdk() -> str:
186
+ if importlib.util.find_spec("mcp") is None:
187
+ raise RuntimeError("the 'mcp' package is not installed")
188
+ from importlib.metadata import version
189
+
190
+ return f"mcp {version('mcp')}"
191
+
192
+ def project() -> str:
193
+ found = detect_project()
194
+ return found.space_id if found else "none detected (memories default to personal)"
195
+
196
+ check("python", python_version)
197
+ check("sqlite", fts5)
198
+ check("database", database)
199
+ check("embeddings", embedder)
200
+ check("mcp sdk", mcp_sdk)
201
+ check("project", project)
202
+
203
+ for name, ok, detail in checks:
204
+ print(f"{'ok ' if ok else 'ERR'} {name:<11} {detail}")
205
+ return 0 if all(ok for _, ok, _ in checks) else 1
206
+
207
+
208
+ def cmd_remember(args: argparse.Namespace) -> int:
209
+ service, _ = _open()
210
+ try:
211
+ project = _project_space() if args.scope == "project" else None
212
+ result = service.remember(
213
+ args.content,
214
+ memory_type=args.type,
215
+ space_id=project or service.default_space,
216
+ importance=args.importance,
217
+ source="cli",
218
+ )
219
+ finally:
220
+ service.close()
221
+ verb = "Already known" if result.deduplicated else "Remembered"
222
+ print(f"{verb}: {result.memory.id} [{result.memory.space_id}]")
223
+ return 0
224
+
225
+
226
+ def cmd_recall(args: argparse.Namespace) -> int:
227
+ service, _ = _open()
228
+ try:
229
+ spaces = [s for s in (_project_space(), service.default_space) if s]
230
+ _print_hits(service.search(args.query, space_ids=spaces, limit=args.limit), args.json)
231
+ finally:
232
+ service.close()
233
+ return 0
234
+
235
+
236
+ def cmd_forget(args: argparse.Namespace) -> int:
237
+ service, _ = _open()
238
+ try:
239
+ service.forget(args.memory_id, hard=not args.soft)
240
+ finally:
241
+ service.close()
242
+ print(("Invalidated " if args.soft else "Deleted ") + args.memory_id)
243
+ return 0
244
+
245
+
246
+ def cmd_list(args: argparse.Namespace) -> int:
247
+ service, _ = _open()
248
+ try:
249
+ _print_records(
250
+ service.list_memories(space_id=args.space, limit=args.limit, offset=args.offset),
251
+ args.json,
252
+ )
253
+ finally:
254
+ service.close()
255
+ return 0
256
+
257
+
258
+ def cmd_stats(args: argparse.Namespace) -> int:
259
+ service, _ = _open()
260
+ try:
261
+ print(json.dumps(service.stats(), indent=2))
262
+ finally:
263
+ service.close()
264
+ return 0
265
+
266
+
267
+ def cmd_export(args: argparse.Namespace) -> int:
268
+ service, _ = _open()
269
+ count = 0
270
+ out = open(args.output, "w", encoding="utf-8") if args.output else sys.stdout # noqa: SIM115
271
+ try:
272
+ offset = 0
273
+ while batch := service.list_memories(limit=500, offset=offset):
274
+ for record in batch:
275
+ out.write(record.model_dump_json() + "\n")
276
+ count += len(batch)
277
+ offset += len(batch)
278
+ finally:
279
+ service.close()
280
+ if out is not sys.stdout:
281
+ out.close()
282
+ print(f"Exported {count} memories.", file=sys.stderr)
283
+ return 0
284
+
285
+
286
+ def cmd_reindex(args: argparse.Namespace) -> int:
287
+ service, _ = _open()
288
+ try:
289
+ count = service.reindex_embeddings()
290
+ model = service.embedding_model
291
+ finally:
292
+ service.close()
293
+ print(f"Embedded {count} memories with {model}.")
294
+ return 0
295
+
296
+
297
+ def cmd_bench(args: argparse.Namespace) -> int:
298
+ from mindtrail.embeddings import create_embedder
299
+ from mindtrail.embeddings.rerankers import create_reranker
300
+ from mindtrail.evaluation import load_dataset, run_benchmark
301
+ from mindtrail.evaluation.locomo import fetch_locomo, run_locomo
302
+
303
+ config = MindtrailConfig.from_env()
304
+ embedder = create_embedder(
305
+ args.embedder, model=config.embedding_model, cache_dir=config.model_dir
306
+ )
307
+ reranker = create_reranker(args.reranker, cache_dir=config.model_dir)
308
+ for source in args.dataset:
309
+ if source.startswith("locomo"):
310
+ split = source.partition(":")[2] or "test"
311
+ path = fetch_locomo(config.home / "benchmarks")
312
+ locomo = run_locomo(path, embedder, reranker=reranker, split=split)
313
+ print(locomo.to_markdown() + "\n")
314
+ if args.json:
315
+ path = args.json.with_stem(f"{args.json.stem}-locomo-{split}")
316
+ path.write_text(locomo.model_dump_json(indent=2), encoding="utf-8")
317
+ continue
318
+ report = run_benchmark(load_dataset(source), embedder, k=args.k, reranker=reranker)
319
+ print(report.to_markdown() + "\n")
320
+ if args.failures:
321
+ for result in report.failures():
322
+ print(
323
+ f" {result.id:<5} {result.query!r}\n"
324
+ f" got {result.retrieved} want {result.relevant}"
325
+ + (f" forbid {result.forbidden}" if result.forbidden else "")
326
+ )
327
+ print()
328
+ if args.json:
329
+ path = (
330
+ args.json
331
+ if len(args.dataset) == 1
332
+ else args.json.with_stem(f"{args.json.stem}-{Path(source).stem}")
333
+ )
334
+ path.write_text(report.model_dump_json(indent=2), encoding="utf-8")
335
+ return 0
336
+
337
+
338
+ # -- parser --------------------------------------------------------------------------------
339
+
340
+
341
+ def build_parser() -> argparse.ArgumentParser:
342
+ parser = argparse.ArgumentParser(
343
+ prog="mindtrail", description="Persistent memory for AI agents, over MCP."
344
+ )
345
+ parser.add_argument("--version", action="version", version=f"mindtrail {__version__}")
346
+ sub = parser.add_subparsers(dest="command", required=True, metavar="command")
347
+
348
+ p = sub.add_parser("serve", help="run the MCP server over stdio")
349
+ p.add_argument("--tools", choices=["core", "full"], help="tool profile (default: core)")
350
+ p.set_defaults(fn=cmd_serve)
351
+
352
+ p = sub.add_parser("init", help="create the local store and print client setup")
353
+ p.add_argument("--client", choices=CLIENTS)
354
+ p.set_defaults(fn=cmd_init)
355
+
356
+ p = sub.add_parser("doctor", help="check the installation")
357
+ p.set_defaults(fn=cmd_doctor)
358
+
359
+ p = sub.add_parser("remember", help="store a memory")
360
+ p.add_argument("content")
361
+ p.add_argument("--scope", choices=["project", "personal"], default="project")
362
+ p.add_argument("--type", default="semantic")
363
+ p.add_argument("--importance", type=float, default=0.5)
364
+ p.set_defaults(fn=cmd_remember)
365
+
366
+ p = sub.add_parser("recall", help="search project and personal memories")
367
+ p.add_argument("query")
368
+ p.add_argument("--limit", type=int, default=5)
369
+ p.add_argument("--json", action="store_true")
370
+ p.set_defaults(fn=cmd_recall)
371
+
372
+ p = sub.add_parser("forget", help="delete a memory")
373
+ p.add_argument("memory_id")
374
+ p.add_argument("--soft", action="store_true", help="invalidate but keep history")
375
+ p.set_defaults(fn=cmd_forget)
376
+
377
+ p = sub.add_parser("list", help="list memories, newest first")
378
+ p.add_argument("--space")
379
+ p.add_argument("--limit", type=int, default=20)
380
+ p.add_argument("--offset", type=int, default=0)
381
+ p.add_argument("--json", action="store_true")
382
+ p.set_defaults(fn=cmd_list)
383
+
384
+ p = sub.add_parser("stats", help="memory counts by space and type")
385
+ p.set_defaults(fn=cmd_stats)
386
+
387
+ p = sub.add_parser("export", help="export all memories as JSON Lines")
388
+ p.add_argument("-o", "--output", type=Path)
389
+ p.set_defaults(fn=cmd_export)
390
+
391
+ p = sub.add_parser("bench", help="run the retrieval benchmark")
392
+ p.add_argument(
393
+ "--dataset",
394
+ nargs="+",
395
+ default=["dev", "holdout"],
396
+ help="bundled datasets (dev, holdout, holdout-v2), locomo[:dev|test|all] "
397
+ "(downloaded; CC BY-NC 4.0), or paths to dataset files",
398
+ )
399
+ p.add_argument("--embedder", choices=["auto", "hashing", "fastembed"], default="auto")
400
+ p.add_argument(
401
+ "--reranker", default="auto", help="auto, none, or a fastembed cross-encoder model id"
402
+ )
403
+ p.add_argument("-k", type=int, default=5)
404
+ p.add_argument("--json", type=Path, help="write the full report as JSON")
405
+ p.add_argument("--failures", action="store_true", help="list queries that missed")
406
+ p.set_defaults(fn=cmd_bench)
407
+
408
+ p = sub.add_parser("reindex", help="embed memories missing a vector for the current model")
409
+ p.set_defaults(fn=cmd_reindex)
410
+ return parser
411
+
412
+
413
+ def main(argv: Sequence[str] | None = None) -> int:
414
+ logging.basicConfig(
415
+ level=os.environ.get("MINDTRAIL_LOG_LEVEL", "WARNING").upper(),
416
+ stream=sys.stderr,
417
+ format="%(levelname)s %(name)s: %(message)s",
418
+ )
419
+ args = build_parser().parse_args(argv)
420
+ try:
421
+ code: int = args.fn(args)
422
+ except (MindtrailError, ValueError) as exc:
423
+ print(f"error: {exc}", file=sys.stderr)
424
+ return 1
425
+ return code
File without changes
@@ -0,0 +1,46 @@
1
+ """Runtime configuration, read from environment variables."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import Literal, cast
9
+
10
+ from mindtrail.core.models import DEFAULT_SPACE, validate_space_id
11
+
12
+ EmbedderKind = Literal["auto", "hashing", "fastembed"]
13
+ _EMBEDDERS: tuple[EmbedderKind, ...] = ("auto", "hashing", "fastembed")
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class MindtrailConfig:
18
+ home: Path
19
+ embedder: EmbedderKind = "auto"
20
+ default_space: str = DEFAULT_SPACE
21
+ reranker: str = "auto" # "auto", "none" or a fastembed cross-encoder model id
22
+ embedding_model: str | None = None # fastembed model id; None = the calibrated default
23
+
24
+ def __post_init__(self) -> None:
25
+ if self.embedder not in _EMBEDDERS:
26
+ raise ValueError(f"embedder must be one of {_EMBEDDERS}, got {self.embedder!r}")
27
+ object.__setattr__(self, "default_space", validate_space_id(self.default_space))
28
+
29
+ @property
30
+ def db_path(self) -> Path:
31
+ return self.home / "mindtrail.db"
32
+
33
+ @property
34
+ def model_dir(self) -> Path:
35
+ return self.home / "models"
36
+
37
+ @classmethod
38
+ def from_env(cls) -> MindtrailConfig:
39
+ home = Path(os.environ.get("MINDTRAIL_HOME") or Path.home() / ".mindtrail").expanduser()
40
+ return cls(
41
+ home=home,
42
+ embedder=cast(EmbedderKind, os.environ.get("MINDTRAIL_EMBEDDER", "auto")),
43
+ default_space=os.environ.get("MINDTRAIL_DEFAULT_SPACE", DEFAULT_SPACE),
44
+ reranker=os.environ.get("MINDTRAIL_RERANKER", "auto").strip() or "auto",
45
+ embedding_model=os.environ.get("MINDTRAIL_EMBEDDING_MODEL", "").strip() or None,
46
+ )
@@ -0,0 +1,27 @@
1
+ """Errors raised by the memory engine. Transports map these to their own error formats."""
2
+
3
+ from uuid import UUID
4
+
5
+
6
+ class MindtrailError(Exception):
7
+ """Base class for all Mindtrail errors."""
8
+
9
+
10
+ class InvalidMemoryError(MindtrailError):
11
+ """Input failed validation."""
12
+
13
+
14
+ class MemoryNotFoundError(MindtrailError):
15
+ def __init__(self, memory_id: UUID | str) -> None:
16
+ super().__init__(f"memory {memory_id} not found")
17
+ self.memory_id = str(memory_id)
18
+
19
+
20
+ class SecretDetectedError(MindtrailError):
21
+ """Content looks like it contains a credential; storing it is refused."""
22
+
23
+ def __init__(self, kinds: list[str]) -> None:
24
+ super().__init__(
25
+ "refusing to store content that appears to contain secrets: " + ", ".join(kinds)
26
+ )
27
+ self.kinds = kinds
@@ -0,0 +1,127 @@
1
+ """Domain models shared by every Mindtrail interface (MCP, REST, SDK, CLI)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from datetime import UTC, datetime
8
+ from enum import StrEnum
9
+ from typing import Any
10
+ from uuid import UUID, uuid4
11
+
12
+ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
13
+
14
+ MAX_CONTENT_CHARS = 8_000
15
+ MAX_METADATA_BYTES = 4_000
16
+ DEFAULT_SPACE = "personal"
17
+ _SPACE_ID = re.compile(r"^[a-z0-9][a-z0-9._:/-]{0,127}$")
18
+
19
+
20
+ def utcnow() -> datetime:
21
+ return datetime.now(UTC)
22
+
23
+
24
+ def validate_space_id(value: str) -> str:
25
+ value = value.strip().lower()
26
+ if not _SPACE_ID.match(value):
27
+ raise ValueError(
28
+ "space_id must be 1-128 characters of lowercase letters, digits and . _ : / -"
29
+ )
30
+ return value
31
+
32
+
33
+ class MemoryType(StrEnum):
34
+ SEMANTIC = "semantic" # stable facts and preferences
35
+ EPISODIC = "episodic" # events and interactions
36
+ TEMPORAL = "temporal" # facts with a validity period
37
+ REFLECTIVE = "reflective" # consolidated patterns and insights
38
+
39
+
40
+ class MemoryRecord(BaseModel):
41
+ model_config = ConfigDict(frozen=True)
42
+
43
+ id: UUID = Field(default_factory=uuid4)
44
+ tenant_id: str = "local"
45
+ space_id: str = DEFAULT_SPACE
46
+
47
+ content: str = Field(min_length=1, max_length=MAX_CONTENT_CHARS)
48
+ memory_type: MemoryType = MemoryType.SEMANTIC
49
+ source: str | None = Field(default=None, max_length=200)
50
+
51
+ created_at: datetime = Field(default_factory=utcnow)
52
+ updated_at: datetime = Field(default_factory=utcnow)
53
+ valid_from: datetime | None = None
54
+ valid_until: datetime | None = None
55
+
56
+ importance: float = Field(default=0.5, ge=0.0, le=1.0)
57
+ confidence: float = Field(default=1.0, ge=0.0, le=1.0)
58
+ superseded_by: UUID | None = None
59
+
60
+ metadata: dict[str, Any] = Field(default_factory=dict)
61
+
62
+ @field_validator("content")
63
+ @classmethod
64
+ def _strip_content(cls, value: str) -> str:
65
+ value = value.strip()
66
+ if not value:
67
+ raise ValueError("content must not be blank")
68
+ return value
69
+
70
+ @field_validator("space_id")
71
+ @classmethod
72
+ def _check_space(cls, value: str) -> str:
73
+ return validate_space_id(value)
74
+
75
+ @field_validator("created_at", "updated_at", "valid_from", "valid_until")
76
+ @classmethod
77
+ def _require_aware(cls, value: datetime | None) -> datetime | None:
78
+ if value is None:
79
+ return None
80
+ if value.tzinfo is None:
81
+ raise ValueError("timestamps must be timezone-aware")
82
+ return value.astimezone(UTC)
83
+
84
+ @field_validator("metadata")
85
+ @classmethod
86
+ def _check_metadata(cls, value: dict[str, Any]) -> dict[str, Any]:
87
+ try:
88
+ encoded = json.dumps(value)
89
+ except (TypeError, ValueError) as exc:
90
+ raise ValueError("metadata must be JSON-serializable") from exc
91
+ if len(encoded.encode()) > MAX_METADATA_BYTES:
92
+ raise ValueError(f"metadata must be at most {MAX_METADATA_BYTES} bytes as JSON")
93
+ return value
94
+
95
+ @model_validator(mode="after")
96
+ def _check_validity_window(self) -> MemoryRecord:
97
+ if self.valid_from and self.valid_until and self.valid_until <= self.valid_from:
98
+ raise ValueError("valid_until must be after valid_from")
99
+ return self
100
+
101
+ def is_active(self, at: datetime) -> bool:
102
+ """Not superseded and inside its validity window at time ``at``."""
103
+ if self.superseded_by is not None:
104
+ return False
105
+ if self.valid_from is not None and self.valid_from > at:
106
+ return False
107
+ return self.valid_until is None or self.valid_until > at
108
+
109
+
110
+ class RememberResult(BaseModel):
111
+ memory: MemoryRecord
112
+ deduplicated: bool = False
113
+ superseded_id: UUID | None = None
114
+
115
+
116
+ class SearchHit(BaseModel):
117
+ memory: MemoryRecord
118
+ score: float
119
+ signals: dict[str, float] = Field(default_factory=dict)
120
+
121
+
122
+ class MemoryContext(BaseModel):
123
+ query: str
124
+ text: str
125
+ memories: list[SearchHit]
126
+ token_estimate: int
127
+ omitted: int = 0