agentrecall-db 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.
@@ -0,0 +1,33 @@
1
+ """agentrecall — agent memory in a single SQLite file.
2
+
3
+ No vector DB, no server, no cloud. Keyword recall works out of the box on stdlib alone;
4
+ install ``agentrecall[semantic]`` for torch-free hybrid semantic search.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from .embeddings import Embedder, Model2VecEmbedder, get_default_embedder
10
+ from .errors import (
11
+ AgentRecallError,
12
+ EmbeddingsUnavailable,
13
+ MemoryNotFound,
14
+ StoreError,
15
+ )
16
+ from .memory import Memory
17
+ from .models import MemoryHit, MemoryRecord
18
+
19
+ __version__ = "0.1.0"
20
+
21
+ __all__ = [
22
+ "Memory",
23
+ "MemoryRecord",
24
+ "MemoryHit",
25
+ "Embedder",
26
+ "Model2VecEmbedder",
27
+ "get_default_embedder",
28
+ "AgentRecallError",
29
+ "MemoryNotFound",
30
+ "EmbeddingsUnavailable",
31
+ "StoreError",
32
+ "__version__",
33
+ ]
agentrecall/cli.py ADDED
@@ -0,0 +1,209 @@
1
+ """``agentrecall`` command-line interface (argparse, stdlib only).
2
+
3
+ All subcommands honour ``--db`` and the ``AGENTRECALL_*`` environment variables. Use
4
+ ``--json`` on read commands for machine-readable output.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import json
11
+ import sys
12
+ from datetime import datetime
13
+
14
+ from . import __version__
15
+ from .config import Settings
16
+ from .memory import Memory
17
+
18
+
19
+ def _split_tags(value: str | None) -> list[str] | None:
20
+ if not value:
21
+ return None
22
+ tags = [t.strip() for t in value.split(",") if t.strip()]
23
+ return tags or None
24
+
25
+
26
+ def _build_memory(args: argparse.Namespace) -> Memory:
27
+ settings = Settings.from_env()
28
+ db_path = getattr(args, "db", None) or settings.db_path
29
+ namespace = getattr(args, "namespace", None) or settings.namespace
30
+ embeddings = settings.embeddings_value()
31
+ if getattr(args, "embeddings", None):
32
+ embeddings = {"auto": "auto", "true": True, "false": False}[args.embeddings]
33
+ return Memory(db_path, namespace=namespace, embeddings=embeddings)
34
+
35
+
36
+ def _truncate(text: str, width: int = 80) -> str:
37
+ text = text.replace("\n", " ")
38
+ return text if len(text) <= width else text[: width - 1] + "…"
39
+
40
+
41
+ def _cmd_add(args: argparse.Namespace) -> int:
42
+ with _build_memory(args) as mem:
43
+ record = mem.add(
44
+ args.content,
45
+ tags=_split_tags(args.tags),
46
+ importance=args.importance,
47
+ )
48
+ print(f"#{record.id} {_truncate(record.content)}")
49
+ return 0
50
+
51
+
52
+ def _cmd_search(args: argparse.Namespace) -> int:
53
+ with _build_memory(args) as mem:
54
+ hits = mem.search(args.query, k=args.k, tags=_split_tags(args.tags))
55
+ if args.json:
56
+ print(json.dumps([h.to_dict() for h in hits], ensure_ascii=False, indent=2))
57
+ return 0
58
+ if not hits:
59
+ print("(no matches)")
60
+ return 0
61
+ for hit in hits:
62
+ print(f"{hit.score:6.3f} #{hit.id:<4} {_truncate(hit.content)}")
63
+ return 0
64
+
65
+
66
+ def _cmd_list(args: argparse.Namespace) -> int:
67
+ with _build_memory(args) as mem:
68
+ records = mem.all(limit=args.limit, tags=_split_tags(args.tags))
69
+ if args.json:
70
+ print(json.dumps([r.to_dict() for r in records], ensure_ascii=False, indent=2))
71
+ return 0
72
+ if not records:
73
+ print("(empty)")
74
+ return 0
75
+ for record in records:
76
+ tags = f" [{', '.join(record.tags)}]" if record.tags else ""
77
+ print(f"#{record.id:<4} {_truncate(record.content)}{tags}")
78
+ return 0
79
+
80
+
81
+ def _cmd_get(args: argparse.Namespace) -> int:
82
+ from .errors import MemoryNotFound
83
+
84
+ with _build_memory(args) as mem:
85
+ try:
86
+ record = mem.get(args.id)
87
+ except MemoryNotFound:
88
+ print(f"no memory with id {args.id}", file=sys.stderr)
89
+ return 1
90
+ print(json.dumps(record.to_dict(), ensure_ascii=False, indent=2))
91
+ return 0
92
+
93
+
94
+ def _cmd_delete(args: argparse.Namespace) -> int:
95
+ with _build_memory(args) as mem:
96
+ ok = mem.delete(args.id)
97
+ print("deleted" if ok else "not found")
98
+ return 0 if ok else 1
99
+
100
+
101
+ def _cmd_forget(args: argparse.Namespace) -> int:
102
+ before = datetime.fromisoformat(args.before) if args.before else None
103
+ with _build_memory(args) as mem:
104
+ removed = mem.forget(before=before, keep_last=args.keep_last)
105
+ print(f"forgot {removed} memorie(s)")
106
+ return 0
107
+
108
+
109
+ def _cmd_stats(args: argparse.Namespace) -> int:
110
+ with _build_memory(args) as mem:
111
+ data = {
112
+ "namespace": mem.namespace,
113
+ "count": mem.count(),
114
+ "semantic": mem.semantic_enabled,
115
+ }
116
+ print(json.dumps(data, ensure_ascii=False, indent=2))
117
+ return 0
118
+
119
+
120
+ def _cmd_export(args: argparse.Namespace) -> int:
121
+ with _build_memory(args) as mem:
122
+ records = mem.all()
123
+ if args.format == "json":
124
+ print(json.dumps([r.to_dict() for r in records], ensure_ascii=False, indent=2))
125
+ else: # markdown
126
+ for record in records:
127
+ tags = f" `{' '.join(record.tags)}`" if record.tags else ""
128
+ print(f"- **#{record.id}**{tags} — {record.content}")
129
+ return 0
130
+
131
+
132
+ def _cmd_serve(args: argparse.Namespace) -> int:
133
+ from .mcp_server import serve
134
+
135
+ mem = _build_memory(args)
136
+ try:
137
+ serve(mem, transport=args.transport)
138
+ finally:
139
+ mem.close()
140
+ return 0
141
+
142
+
143
+ def build_parser() -> argparse.ArgumentParser:
144
+ parser = argparse.ArgumentParser(
145
+ prog="agentrecall",
146
+ description="Agent memory in a single SQLite file.",
147
+ )
148
+ parser.add_argument("--version", action="version", version=f"agentrecall {__version__}")
149
+ parser.add_argument("--db", help="database file (env AGENTRECALL_DB)")
150
+ parser.add_argument("--namespace", help="namespace (env AGENTRECALL_NAMESPACE)")
151
+ parser.add_argument(
152
+ "--embeddings", choices=["auto", "true", "false"], help="semantic search mode"
153
+ )
154
+ sub = parser.add_subparsers(dest="command", required=True)
155
+
156
+ p_add = sub.add_parser("add", help="store a memory")
157
+ p_add.add_argument("content")
158
+ p_add.add_argument("--tags", help="comma-separated tags")
159
+ p_add.add_argument("--importance", type=float, default=1.0)
160
+ p_add.set_defaults(func=_cmd_add)
161
+
162
+ p_search = sub.add_parser("search", help="search memories")
163
+ p_search.add_argument("query")
164
+ p_search.add_argument("-k", type=int, default=5)
165
+ p_search.add_argument("--tags", help="comma-separated tags filter")
166
+ p_search.add_argument("--json", action="store_true")
167
+ p_search.set_defaults(func=_cmd_search)
168
+
169
+ p_list = sub.add_parser("list", help="list memories")
170
+ p_list.add_argument("--limit", type=int, default=20)
171
+ p_list.add_argument("--tags", help="comma-separated tags filter")
172
+ p_list.add_argument("--json", action="store_true")
173
+ p_list.set_defaults(func=_cmd_list)
174
+
175
+ p_get = sub.add_parser("get", help="show one memory")
176
+ p_get.add_argument("id", type=int)
177
+ p_get.set_defaults(func=_cmd_get)
178
+
179
+ p_delete = sub.add_parser("delete", help="delete one memory")
180
+ p_delete.add_argument("id", type=int)
181
+ p_delete.set_defaults(func=_cmd_delete)
182
+
183
+ p_forget = sub.add_parser("forget", help="bulk-delete by age or count")
184
+ p_forget.add_argument("--before", help="ISO-8601 datetime; delete older")
185
+ p_forget.add_argument("--keep-last", type=int, dest="keep_last")
186
+ p_forget.set_defaults(func=_cmd_forget)
187
+
188
+ p_stats = sub.add_parser("stats", help="show counts and config")
189
+ p_stats.set_defaults(func=_cmd_stats)
190
+
191
+ p_export = sub.add_parser("export", help="dump memories")
192
+ p_export.add_argument("--format", choices=["json", "md"], default="json")
193
+ p_export.set_defaults(func=_cmd_export)
194
+
195
+ p_serve = sub.add_parser("serve", help="run the MCP server")
196
+ p_serve.add_argument("--transport", default="stdio")
197
+ p_serve.set_defaults(func=_cmd_serve)
198
+
199
+ return parser
200
+
201
+
202
+ def main(argv: list[str] | None = None) -> int:
203
+ parser = build_parser()
204
+ args = parser.parse_args(argv)
205
+ return args.func(args)
206
+
207
+
208
+ if __name__ == "__main__": # pragma: no cover
209
+ raise SystemExit(main())
agentrecall/config.py ADDED
@@ -0,0 +1,42 @@
1
+ """Lightweight settings read from ``AGENTRECALL_*`` environment variables.
2
+
3
+ Stdlib only — no pydantic, no extra dependency. Used by the CLI and MCP server so the
4
+ same database can be configured once via the environment.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ from dataclasses import dataclass
11
+
12
+ from .embeddings import DEFAULT_MODEL
13
+
14
+
15
+ @dataclass
16
+ class Settings:
17
+ db_path: str = "agentrecall.db"
18
+ namespace: str = "default"
19
+ embeddings: str = "auto" # "auto" | "true" | "false"
20
+ model: str = DEFAULT_MODEL
21
+
22
+ @classmethod
23
+ def from_env(cls) -> Settings:
24
+ env = os.environ
25
+ return cls(
26
+ db_path=env.get("AGENTRECALL_DB", cls.db_path),
27
+ namespace=env.get("AGENTRECALL_NAMESPACE", cls.namespace),
28
+ embeddings=env.get("AGENTRECALL_EMBEDDINGS", cls.embeddings).lower(),
29
+ model=env.get("AGENTRECALL_MODEL", cls.model),
30
+ )
31
+
32
+ def embeddings_value(self) -> bool | str:
33
+ """Map the string setting to the ``Memory(embeddings=...)`` argument."""
34
+ value = self.embeddings.lower()
35
+ if value == "true":
36
+ return True
37
+ if value == "false":
38
+ return False
39
+ return "auto"
40
+
41
+
42
+ __all__ = ["Settings"]
@@ -0,0 +1,84 @@
1
+ """Embeddings for semantic recall — optional and torch-free.
2
+
3
+ The default :class:`Model2VecEmbedder` wraps `model2vec <https://github.com/MinishLab/model2vec>`_
4
+ static embeddings: ~10 MB models, CPU-only, no PyTorch. Bring your own embedder by passing
5
+ any object that satisfies the :class:`Embedder` protocol to ``Memory(embedder=...)``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Protocol, runtime_checkable
11
+
12
+ from .errors import EmbeddingsUnavailable
13
+
14
+ DEFAULT_MODEL = "minishlab/potion-base-8M"
15
+
16
+
17
+ @runtime_checkable
18
+ class Embedder(Protocol):
19
+ """Anything that can turn texts into fixed-length vectors.
20
+
21
+ Implementations must be deterministic for a given input and return one vector per
22
+ input text, each of length :attr:`dim`.
23
+ """
24
+
25
+ @property
26
+ def dim(self) -> int: ...
27
+
28
+ def embed(self, texts: list[str]) -> list[list[float]]: ...
29
+
30
+
31
+ class Model2VecEmbedder:
32
+ """Static-embedding embedder (no torch, no GPU).
33
+
34
+ The model is loaded lazily on the first :meth:`embed` call so that constructing a
35
+ :class:`~agentrecall.memory.Memory` in ``embeddings="auto"`` mode is cheap. Raises
36
+ :class:`~agentrecall.errors.EmbeddingsUnavailable` if ``model2vec`` is not installed.
37
+ """
38
+
39
+ def __init__(self, model: str = DEFAULT_MODEL) -> None:
40
+ self._model_name = model
41
+ self._model = None
42
+ self._dim: int | None = None
43
+
44
+ def _ensure_loaded(self) -> None:
45
+ if self._model is not None:
46
+ return
47
+ try:
48
+ from model2vec import StaticModel
49
+ except ImportError as exc: # pragma: no cover - exercised via guarded tests
50
+ raise EmbeddingsUnavailable(
51
+ "Semantic search needs the optional 'semantic' extra. "
52
+ "Install it with: pip install 'agentrecall[semantic]'"
53
+ ) from exc
54
+ self._model = StaticModel.from_pretrained(self._model_name)
55
+ # model2vec exposes the embedding dimension on the loaded model.
56
+ self._dim = int(self._model.dim)
57
+
58
+ @property
59
+ def dim(self) -> int:
60
+ self._ensure_loaded()
61
+ assert self._dim is not None
62
+ return self._dim
63
+
64
+ def embed(self, texts: list[str]) -> list[list[float]]:
65
+ self._ensure_loaded()
66
+ assert self._model is not None
67
+ vectors = self._model.encode(list(texts))
68
+ # model2vec returns a numpy array; normalise to plain Python floats.
69
+ return [[float(x) for x in row] for row in vectors]
70
+
71
+
72
+ def get_default_embedder() -> Embedder | None:
73
+ """Return a :class:`Model2VecEmbedder` if ``model2vec`` is importable, else ``None``.
74
+
75
+ Never raises — this is the probe used by ``embeddings="auto"``.
76
+ """
77
+ import importlib.util
78
+
79
+ if importlib.util.find_spec("model2vec") is None:
80
+ return None
81
+ return Model2VecEmbedder()
82
+
83
+
84
+ __all__ = ["Embedder", "Model2VecEmbedder", "get_default_embedder", "DEFAULT_MODEL"]
agentrecall/errors.py ADDED
@@ -0,0 +1,36 @@
1
+ """Exception hierarchy for agentrecall.
2
+
3
+ All errors raised by the library subclass :class:`AgentRecallError`, so callers can
4
+ ``except AgentRecallError`` to catch anything the library throws.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+
10
+ class AgentRecallError(Exception):
11
+ """Base class for all agentrecall errors."""
12
+
13
+
14
+ class MemoryNotFound(AgentRecallError):
15
+ """Raised by ``get()`` / ``update()`` / ``delete()`` when the id does not exist."""
16
+
17
+ def __init__(self, memory_id: int) -> None:
18
+ self.memory_id = memory_id
19
+ super().__init__(f"No memory with id {memory_id!r}")
20
+
21
+
22
+ class EmbeddingsUnavailable(AgentRecallError):
23
+ """Raised when semantic mode is required (``embeddings=True``) but the optional
24
+ ``[semantic]`` extra (model2vec / sqlite-vec) is not installed or cannot load."""
25
+
26
+
27
+ class StoreError(AgentRecallError):
28
+ """Wraps an unexpected SQLite-level failure."""
29
+
30
+
31
+ __all__ = [
32
+ "AgentRecallError",
33
+ "MemoryNotFound",
34
+ "EmbeddingsUnavailable",
35
+ "StoreError",
36
+ ]
@@ -0,0 +1,70 @@
1
+ """Expose a :class:`~agentrecall.memory.Memory` as an MCP server.
2
+
3
+ This is a drop-in, embeddings-capable alternative to the official memory MCP server
4
+ (which persists to a keyword-only JSONL flat file). Here memories live in a portable
5
+ SQLite file and recall can be hybrid keyword + semantic.
6
+
7
+ Requires the optional ``mcp`` extra: ``pip install 'agentrecall[mcp]'``.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from .memory import Memory
13
+
14
+
15
+ def build_mcp_server(memory: Memory, *, name: str = "agentrecall"):
16
+ """Return a configured FastMCP server exposing remember/recall/forget tools."""
17
+ try:
18
+ from mcp.server.fastmcp import FastMCP
19
+ except ImportError as exc: # pragma: no cover - exercised only without the extra
20
+ raise ImportError(
21
+ "The agentrecall MCP server needs the optional 'mcp' extra. "
22
+ "Install it with: pip install 'agentrecall[mcp]'"
23
+ ) from exc
24
+
25
+ server = FastMCP(name)
26
+
27
+ @server.tool()
28
+ def remember(
29
+ content: str,
30
+ tags: list[str] | None = None,
31
+ metadata: dict | None = None,
32
+ importance: float = 1.0,
33
+ ) -> dict:
34
+ """Store a new memory verbatim and return the stored record."""
35
+ record = memory.add(content, tags=tags, metadata=metadata, importance=importance)
36
+ return record.to_dict()
37
+
38
+ @server.tool()
39
+ def recall(query: str, k: int = 5, tags: list[str] | None = None) -> list[dict]:
40
+ """Search stored memories; returns the most relevant first."""
41
+ return [hit.to_dict() for hit in memory.search(query, k=k, tags=tags)]
42
+
43
+ @server.tool()
44
+ def forget(memory_id: int) -> bool:
45
+ """Delete a memory by id. Returns True if a row was removed."""
46
+ return memory.delete(memory_id)
47
+
48
+ @server.tool()
49
+ def list_memories(limit: int = 20) -> list[dict]:
50
+ """List the most recent memories."""
51
+ return [record.to_dict() for record in memory.all(limit=limit)]
52
+
53
+ @server.tool()
54
+ def memory_stats() -> dict:
55
+ """Return counts and the active configuration."""
56
+ return {
57
+ "count": memory.count(),
58
+ "namespace": memory.namespace,
59
+ "semantic": memory.semantic_enabled,
60
+ }
61
+
62
+ return server
63
+
64
+
65
+ def serve(memory: Memory, *, transport: str = "stdio") -> None:
66
+ """Build and run the MCP server (blocking)."""
67
+ build_mcp_server(memory).run(transport=transport)
68
+
69
+
70
+ __all__ = ["build_mcp_server", "serve"]