agentmemdb 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.
agentmemdb/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """agentmemdb: local-first, shared long-term memory for AI agents, served over MCP."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ __all__ = ["__version__"]
agentmemdb/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from agentmemdb.cli import main
2
+
3
+ main()
agentmemdb/cli.py ADDED
@@ -0,0 +1,54 @@
1
+ """Command-line entry point: run the server over stdio or HTTP, or print the resolved setup."""
2
+
3
+ import argparse
4
+ import json
5
+ import os
6
+ from collections.abc import Sequence
7
+
8
+ from agentmemdb import __version__
9
+ from agentmemdb.config import ConfigError, load_settings
10
+
11
+
12
+ def build_parser() -> argparse.ArgumentParser:
13
+ parser = argparse.ArgumentParser(
14
+ prog="agentmemdb", description="Local-first, shared long-term memory for AI agents (MCP server)"
15
+ )
16
+ parser.add_argument(
17
+ "--transport",
18
+ choices=["stdio", "http"],
19
+ default="stdio",
20
+ help="stdio (default, one process per client) or streamable HTTP on 127.0.0.1",
21
+ )
22
+ parser.add_argument("--port", type=int, default=8765, help="HTTP port (default: 8765); endpoint is /mcp")
23
+ parser.add_argument("--check", action="store_true", help="print the resolved database and config paths, then exit")
24
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
25
+ return parser
26
+
27
+
28
+ def main(argv: Sequence[str] | None = None) -> None:
29
+ parser = build_parser()
30
+ args = parser.parse_args(argv)
31
+ try:
32
+ settings = load_settings()
33
+ except ConfigError as err:
34
+ parser.exit(2, f"agentmemdb: error: {err}\n")
35
+
36
+ if args.check:
37
+ info = {
38
+ "version": __version__,
39
+ "db_path": settings.db_path,
40
+ "db_exists": os.path.exists(settings.db_path),
41
+ "config_path": settings.config_path,
42
+ "config_found": settings.config_found,
43
+ "auto_tags": len(settings.auto_tags),
44
+ }
45
+ print(json.dumps(info, indent=2))
46
+ return
47
+
48
+ from agentmemdb.server import mcp
49
+
50
+ if args.transport == "http":
51
+ mcp.settings.port = args.port
52
+ mcp.run(transport="streamable-http")
53
+ else:
54
+ mcp.run(transport="stdio")
agentmemdb/config.py ADDED
@@ -0,0 +1,92 @@
1
+ """Settings resolved from environment variables and the optional JSON config file."""
2
+
3
+ import json
4
+ import os
5
+ import re
6
+ from dataclasses import dataclass, field
7
+ from typing import Any
8
+
9
+ APP_NAME = "agentmemdb"
10
+
11
+ BASE_INSTRUCTIONS = (
12
+ "Long-term memory that persists across sessions and is shared by every agent connected to it. "
13
+ "Search it (search_memory, find_by_entity, get_thread) when past context could help, and save decisions, "
14
+ "findings and facts worth keeping with save_memory; prefer append_memory over saving a near-duplicate. "
15
+ "search_memory returns snippets; use get_memory (or full_content=true) for the full text. "
16
+ 'Pass save_memory entities as a JSON array of objects, e.g. [{"type":"service","value":"billing-api"}].'
17
+ )
18
+
19
+
20
+ class ConfigError(ValueError):
21
+ """The config file exists but cannot be used."""
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class Settings:
26
+ db_path: str
27
+ config_path: str
28
+ config_found: bool = False
29
+ auto_tags: dict[str, re.Pattern[str]] = field(default_factory=dict)
30
+ instructions: str = BASE_INSTRUCTIONS
31
+
32
+ def tags_for(self, *texts: str) -> list[str]:
33
+ """Auto-tags whose pattern matches the given texts (lowercased)."""
34
+ text = " ".join(texts).lower()
35
+ return [tag for tag, pattern in self.auto_tags.items() if pattern.search(text)]
36
+
37
+
38
+ def config_path() -> str:
39
+ """AGENTMEMDB_CONFIG, else $XDG_CONFIG_HOME/agentmemdb/config.json."""
40
+ return os.path.expanduser(
41
+ os.environ.get("AGENTMEMDB_CONFIG")
42
+ or os.path.join(os.environ.get("XDG_CONFIG_HOME") or "~/.config", APP_NAME, "config.json")
43
+ )
44
+
45
+
46
+ def default_db_path() -> str:
47
+ """$XDG_DATA_HOME/agentmemdb/memory.db (default ~/.local/share/agentmemdb/memory.db)."""
48
+ return os.path.join(os.path.expanduser(os.environ.get("XDG_DATA_HOME") or "~/.local/share"), APP_NAME, "memory.db")
49
+
50
+
51
+ def load_settings() -> Settings:
52
+ """Resolve settings; the database path is AGENTMEMDB_DB_PATH, then config db_path, then the default."""
53
+ path = config_path()
54
+ found = os.path.exists(path)
55
+ config = _read_config(path) if found else {}
56
+ db_path = os.environ.get("AGENTMEMDB_DB_PATH") or config.get("db_path") or default_db_path()
57
+ extra = config.get("instructions", "")
58
+ return Settings(
59
+ db_path=os.path.expanduser(db_path),
60
+ config_path=path,
61
+ config_found=found,
62
+ auto_tags=_compile_auto_tags(path, config.get("auto_tags", {})),
63
+ instructions="\n".join(filter(None, [BASE_INSTRUCTIONS, extra])),
64
+ )
65
+
66
+
67
+ def _read_config(path: str) -> dict[str, Any]:
68
+ try:
69
+ with open(path, encoding="utf-8") as f:
70
+ config = json.load(f)
71
+ except (OSError, json.JSONDecodeError) as err:
72
+ raise ConfigError(f"cannot read config file {path}: {err}") from err
73
+ if not isinstance(config, dict):
74
+ raise ConfigError(f"config file {path} must contain a JSON object")
75
+ for key in ("db_path", "instructions"):
76
+ if not isinstance(config.get(key, ""), str):
77
+ raise ConfigError(f"config file {path}: {key!r} must be a string")
78
+ return config
79
+
80
+
81
+ def _compile_auto_tags(path: str, auto_tags: Any) -> dict[str, re.Pattern[str]]:
82
+ if not isinstance(auto_tags, dict):
83
+ raise ConfigError(f'config file {path}: "auto_tags" must be an object of {{"tag": "regex"}}')
84
+ compiled = {}
85
+ for tag, pattern in auto_tags.items():
86
+ if not isinstance(pattern, str):
87
+ raise ConfigError(f"config file {path}: auto_tags[{tag!r}] must be a regex string")
88
+ try:
89
+ compiled[tag] = re.compile(pattern)
90
+ except re.error as err:
91
+ raise ConfigError(f"config file {path}: auto_tags[{tag!r}] is not a valid regex: {err}") from err
92
+ return compiled
agentmemdb/icon.svg ADDED
@@ -0,0 +1,30 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" role="img" aria-label="agentmemdb">
2
+ <defs>
3
+ <linearGradient id="amdb-bg" x1="0" y1="0" x2="1" y2="1">
4
+ <stop offset="0" stop-color="#4F46E5"/>
5
+ <stop offset="1" stop-color="#9333EA"/>
6
+ </linearGradient>
7
+ <linearGradient id="amdb-disc" x1="0" y1="0" x2="1" y2="0">
8
+ <stop offset="0" stop-color="#FFFFFF"/>
9
+ <stop offset="1" stop-color="#E0E7FF"/>
10
+ </linearGradient>
11
+ <clipPath id="amdb-back"><rect width="256" height="128"/></clipPath>
12
+ <clipPath id="amdb-front"><rect y="128" width="256" height="128"/></clipPath>
13
+ </defs>
14
+ <rect width="256" height="256" rx="60" fill="url(#amdb-bg)"/>
15
+ <g transform="rotate(-18 128 128)" fill="none" stroke="#FBBF24" stroke-width="7">
16
+ <ellipse cx="128" cy="128" rx="104" ry="32" clip-path="url(#amdb-back)"/>
17
+ </g>
18
+ <g fill="url(#amdb-disc)">
19
+ <ellipse cx="128" cy="72" rx="56" ry="19" fill="#FFFFFF"/>
20
+ <path d="M72 82A56 19 0 0 0 184 82V111A56 19 0 0 1 72 111Z"/>
21
+ <path d="M72 121A56 19 0 0 0 184 121V150A56 19 0 0 1 72 150Z"/>
22
+ <path d="M72 160A56 19 0 0 0 184 160V189A56 19 0 0 1 72 189Z"/>
23
+ </g>
24
+ <g transform="rotate(-18 128 128)" fill="#FBBF24">
25
+ <ellipse cx="128" cy="128" rx="104" ry="32" fill="none" stroke="#FBBF24" stroke-width="7" clip-path="url(#amdb-front)"/>
26
+ <circle cx="24" cy="128" r="13"/>
27
+ <circle cx="232" cy="128" r="13"/>
28
+ <circle cx="150" cy="159" r="13"/>
29
+ </g>
30
+ </svg>
agentmemdb/models.py ADDED
@@ -0,0 +1,93 @@
1
+ """Memory categories, importance levels and validation of tool inputs."""
2
+
3
+ import json
4
+ from datetime import datetime, timezone
5
+
6
+ CATEGORIES = (
7
+ "conversation", # summary of a conversation or session
8
+ "decision", # a decision and why it was made
9
+ "incident", # alert or incident investigation and resolution
10
+ "research", # analysis, findings, deep dives
11
+ "person", # a colleague or contact
12
+ "project", # project context, status, goals
13
+ "howto", # procedures and runbooks
14
+ "config", # configuration and environment setup
15
+ "other",
16
+ )
17
+
18
+ IMPORTANCE_LEVELS = ("low", "normal", "high", "critical")
19
+ IMPORTANCE_RANK = {level: rank for rank, level in enumerate(IMPORTANCE_LEVELS)}
20
+
21
+ ENTITIES_HINT = 'Expected a JSON array of objects like [{"type":"service","value":"billing-api"}]'
22
+
23
+
24
+ class ToolError(Exception):
25
+ """Invalid input or missing memory; returned to the agent as {"error": message}."""
26
+
27
+
28
+ def utc_now() -> datetime:
29
+ return datetime.now(timezone.utc)
30
+
31
+
32
+ def parse_timestamp(value: str) -> datetime:
33
+ """Parse YYYY-MM-DD or ISO 8601; naive values are treated as UTC."""
34
+ parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
35
+ return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
36
+
37
+
38
+ def is_expired(expires_at: str | None) -> bool:
39
+ if not expires_at:
40
+ return False
41
+ try:
42
+ return utc_now() > parse_timestamp(expires_at)
43
+ except (ValueError, TypeError, AttributeError):
44
+ return False
45
+
46
+
47
+ def check_category(category: str) -> None:
48
+ if category not in CATEGORIES:
49
+ raise ToolError(f"Invalid category. Must be one of: {list(CATEGORIES)}")
50
+
51
+
52
+ def check_importance(importance: str) -> None:
53
+ if importance not in IMPORTANCE_LEVELS:
54
+ raise ToolError(f"Invalid importance. Must be one of: {list(IMPORTANCE_LEVELS)}")
55
+
56
+
57
+ def check_expiry(expires_at: str) -> None:
58
+ try:
59
+ parse_timestamp(expires_at)
60
+ except ValueError:
61
+ raise ToolError("Invalid expires_at. Use YYYY-MM-DD or an ISO 8601 timestamp") from None
62
+
63
+
64
+ def parse_tags(tags: str) -> list[str]:
65
+ return [t.strip() for t in tags.split(",") if t.strip()]
66
+
67
+
68
+ def parse_entities(entities: str) -> list[tuple[str, str]]:
69
+ """Parse the entities JSON into unique, lowercased (type, value) pairs."""
70
+ if not entities:
71
+ return []
72
+ try:
73
+ parsed = json.loads(entities)
74
+ except json.JSONDecodeError as err:
75
+ raise ToolError(f"Invalid entities JSON: {err}") from None
76
+ if not isinstance(parsed, list):
77
+ raise ToolError(f"Invalid entities: {ENTITIES_HINT}")
78
+ pairs = []
79
+ for entity in parsed:
80
+ if not (
81
+ isinstance(entity, dict)
82
+ and isinstance(entity.get("type"), str)
83
+ and isinstance(entity.get("value"), str)
84
+ and entity["type"].strip()
85
+ and entity["value"].strip()
86
+ ):
87
+ raise ToolError(f"Invalid entity {json.dumps(entity)}: {ENTITIES_HINT}")
88
+ pairs.append((entity["type"].strip().lower(), entity["value"].strip().lower()))
89
+ return list(dict.fromkeys(pairs))
90
+
91
+
92
+ def clamp(limit: int, upper: int = 100) -> int:
93
+ return max(1, min(int(limit), upper))
agentmemdb/py.typed ADDED
File without changes
agentmemdb/search.py ADDED
@@ -0,0 +1,156 @@
1
+ """Relevance search (FTS5 + BM25 with a substring fallback) and duplicate detection."""
2
+
3
+ import re
4
+ import sqlite3
5
+ from typing import Any
6
+
7
+ from agentmemdb.models import IMPORTANCE_LEVELS, IMPORTANCE_RANK
8
+
9
+ Row = dict[str, Any]
10
+
11
+ STOP_WORDS = {"the", "and", "for", "with", "from", "that", "this", "are", "was", "has", "have", "not", "but"}
12
+
13
+ SEARCH_COLUMNS = (
14
+ "m.id, m.topic, m.category, m.tags, m.importance, m.thread_id, m.expires_at, "
15
+ "m.created_at, m.updated_at, LENGTH(m.content) AS content_length"
16
+ )
17
+
18
+ # BM25 weights for (topic, content, tags)
19
+ BM25_WEIGHTS = (10.0, 1.0, 5.0)
20
+
21
+
22
+ def filters(category: str = "", importance: str = "", include_expired: bool = False) -> tuple[str, list[Any]]:
23
+ """SQL conditions on alias m for category, minimum importance and expiry."""
24
+ sql, params = "", []
25
+ if category:
26
+ sql += " AND m.category = ?"
27
+ params.append(category)
28
+ if importance in IMPORTANCE_RANK:
29
+ allowed = IMPORTANCE_LEVELS[IMPORTANCE_RANK[importance] :]
30
+ sql += f" AND m.importance IN ({','.join('?' * len(allowed))})"
31
+ params += allowed
32
+ if not include_expired:
33
+ sql += " AND NOT is_expired(m.expires_at)"
34
+ return sql, params
35
+
36
+
37
+ def search(db: sqlite3.Connection, query: str, filter_sql: str, filter_params: list[Any], limit: int) -> list[Row]:
38
+ """Try the query as typed, then all terms, then any term; fall back to substring matching."""
39
+ for fts_query in fts_queries(query):
40
+ results = _search_fts(db, fts_query, filter_sql, filter_params, limit)
41
+ if results:
42
+ return results
43
+ return _search_substring(db, query, filter_sql, filter_params)[:limit]
44
+
45
+
46
+ def fts_phrase(term: str) -> str:
47
+ return '"' + term.replace('"', '""') + '"'
48
+
49
+
50
+ def fts_queries(query: str) -> list[str]:
51
+ """FTS5 queries in order: as typed, every term (AND), any term (OR)."""
52
+ phrases = [fts_phrase(t) for t in query.split() if re.search(r"\w", t)]
53
+ queries = [query]
54
+ if phrases:
55
+ queries += [" ".join(phrases), " OR ".join(phrases)]
56
+ return list(dict.fromkeys(queries))
57
+
58
+
59
+ def _search_fts(
60
+ db: sqlite3.Connection, fts_query: str, filter_sql: str, filter_params: list[Any], limit: int
61
+ ) -> list[Row]:
62
+ try:
63
+ rows = db.execute(
64
+ f"""SELECT {SEARCH_COLUMNS}, snippet(memories_fts, 1, '', '', '…', 32) AS snippet
65
+ FROM memories_fts f
66
+ JOIN memories m ON m.id = f.rowid
67
+ WHERE memories_fts MATCH ?{filter_sql}
68
+ ORDER BY bm25(memories_fts, {", ".join(map(str, BM25_WEIGHTS))}) LIMIT ?""",
69
+ [fts_query, *filter_params, limit],
70
+ ).fetchall()
71
+ except sqlite3.OperationalError:
72
+ return []
73
+ return [dict(r) for r in rows]
74
+
75
+
76
+ def _search_substring(db: sqlite3.Connection, query: str, filter_sql: str, filter_params: list[Any]) -> list[Row]:
77
+ """Partial words FTS can't match (e.g. "gres" in "postgres"), ranked by words matched."""
78
+ words = [w.lower() for w in re.findall(r"\w+", query) if len(w) >= 2][:5]
79
+ if not words:
80
+ return []
81
+
82
+ where = " OR ".join("(m.topic LIKE ? OR m.content LIKE ? OR m.tags LIKE ?)" for _ in words)
83
+ rows = db.execute(
84
+ f"SELECT {SEARCH_COLUMNS}, m.content FROM memories m WHERE ({where}){filter_sql}",
85
+ [f"%{w}%" for w in words for _ in range(3)] + filter_params,
86
+ ).fetchall()
87
+
88
+ scored = []
89
+ for row in rows:
90
+ result = dict(row)
91
+ content = result.pop("content")
92
+ text = f"{result['topic']} {content} {result['tags']}".lower()
93
+ result["snippet"] = content[:200] + ("…" if len(content) > 200 else "")
94
+ scored.append((sum(w in text for w in words), result["updated_at"], result))
95
+ scored.sort(key=lambda s: (s[0], s[1]), reverse=True)
96
+ return [result for _, _, result in scored]
97
+
98
+
99
+ def topic_words(text: str) -> set[str]:
100
+ """Significant words of a topic, ignoring stop words and short numbers like years."""
101
+ return {
102
+ w
103
+ for w in re.findall(r"\w+", text.lower())
104
+ if len(w) >= 3 and w not in STOP_WORDS and not (w.isdigit() and len(w) <= 4)
105
+ }
106
+
107
+
108
+ def is_similar(words: set[str], other: set[str]) -> bool:
109
+ """At least two shared words, covering 60% of the shorter topic."""
110
+ shared = len(words & other)
111
+ return shared >= 2 and shared / min(len(words), len(other)) >= 0.6
112
+
113
+
114
+ def find_similar(db: sqlite3.Connection, topic: str, limit: int = 3) -> list[Row]:
115
+ """Memories whose topic matches exactly or shares most of its significant words."""
116
+ duplicates = [
117
+ dict(r, exact_match=True)
118
+ for r in db.execute(
119
+ "SELECT id, topic, category, importance, created_at FROM memories WHERE LOWER(topic) = LOWER(?)",
120
+ (topic,),
121
+ )
122
+ ]
123
+ words = topic_words(topic)
124
+ if words:
125
+ rows = db.execute(
126
+ """SELECT m.id, m.topic, m.category, m.importance, m.created_at
127
+ FROM memories_fts f
128
+ JOIN memories m ON m.id = f.rowid
129
+ WHERE memories_fts MATCH ?
130
+ ORDER BY bm25(memories_fts) LIMIT 25""",
131
+ ("topic : (" + " OR ".join(fts_phrase(w) for w in words) + ")",),
132
+ ).fetchall()
133
+ seen = {d["id"] for d in duplicates}
134
+ duplicates += [dict(r) for r in rows if r["id"] not in seen and is_similar(words, topic_words(r["topic"]))]
135
+ return duplicates[:limit]
136
+
137
+
138
+ def group_similar(memories: list[Row], limit: int) -> list[Row]:
139
+ """Group memories (oldest first) whose topics share most of their significant words."""
140
+ indexed = [(m, topic_words(m["topic"])) for m in memories]
141
+ groups: list[Row] = []
142
+ seen: set[int] = set()
143
+ for i, (memory, words) in enumerate(indexed):
144
+ if memory["id"] in seen or not words:
145
+ continue
146
+ similar = [
147
+ other
148
+ for other, other_words in indexed[i + 1 :]
149
+ if other["id"] not in seen and other_words and is_similar(words, other_words)
150
+ ]
151
+ if similar:
152
+ seen.update([memory["id"], *(o["id"] for o in similar)])
153
+ groups.append({"memory": memory, "similar_to": similar})
154
+ if len(groups) >= limit:
155
+ break
156
+ return groups