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 +5 -0
- agentmemdb/__main__.py +3 -0
- agentmemdb/cli.py +54 -0
- agentmemdb/config.py +92 -0
- agentmemdb/icon.svg +30 -0
- agentmemdb/models.py +93 -0
- agentmemdb/py.typed +0 -0
- agentmemdb/search.py +156 -0
- agentmemdb/server.py +618 -0
- agentmemdb/storage.py +136 -0
- agentmemdb-0.1.0.dist-info/METADATA +163 -0
- agentmemdb-0.1.0.dist-info/RECORD +16 -0
- agentmemdb-0.1.0.dist-info/WHEEL +5 -0
- agentmemdb-0.1.0.dist-info/entry_points.txt +2 -0
- agentmemdb-0.1.0.dist-info/licenses/LICENSE +21 -0
- agentmemdb-0.1.0.dist-info/top_level.txt +1 -0
agentmemdb/__init__.py
ADDED
agentmemdb/__main__.py
ADDED
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
|