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.
- mindtrail/__init__.py +17 -0
- mindtrail/__main__.py +3 -0
- mindtrail/cli/__init__.py +0 -0
- mindtrail/cli/main.py +425 -0
- mindtrail/core/__init__.py +0 -0
- mindtrail/core/config.py +46 -0
- mindtrail/core/exceptions.py +27 -0
- mindtrail/core/models.py +127 -0
- mindtrail/core/project.py +84 -0
- mindtrail/core/text.py +52 -0
- mindtrail/embeddings/__init__.py +33 -0
- mindtrail/embeddings/base.py +38 -0
- mindtrail/embeddings/fastembed_provider.py +112 -0
- mindtrail/embeddings/hashing.py +67 -0
- mindtrail/embeddings/rerankers.py +92 -0
- mindtrail/evaluation/__init__.py +6 -0
- mindtrail/evaluation/data/retrieval_holdout_v1.json +85 -0
- mindtrail/evaluation/data/retrieval_holdout_v2.json +92 -0
- mindtrail/evaluation/data/retrieval_v1.json +165 -0
- mindtrail/evaluation/dataset.py +80 -0
- mindtrail/evaluation/locomo.py +235 -0
- mindtrail/evaluation/metrics.py +57 -0
- mindtrail/evaluation/runner.py +225 -0
- mindtrail/mcp/__init__.py +3 -0
- mindtrail/mcp/server.py +259 -0
- mindtrail/memory/__init__.py +0 -0
- mindtrail/memory/context.py +59 -0
- mindtrail/memory/retrieval.py +88 -0
- mindtrail/memory/safety.py +25 -0
- mindtrail/memory/service.py +493 -0
- mindtrail/py.typed +0 -0
- mindtrail/storage/__init__.py +0 -0
- mindtrail/storage/interfaces.py +68 -0
- mindtrail/storage/sqlite.py +348 -0
- mindtrail-0.1.0.dist-info/METADATA +233 -0
- mindtrail-0.1.0.dist-info/RECORD +39 -0
- mindtrail-0.1.0.dist-info/WHEEL +4 -0
- mindtrail-0.1.0.dist-info/entry_points.txt +2 -0
- 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
|
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
|
mindtrail/core/config.py
ADDED
|
@@ -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
|
mindtrail/core/models.py
ADDED
|
@@ -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
|