memento-brain-memory 0.0.1__tar.gz

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,35 @@
1
+ # Python
2
+ __pycache__/
3
+ *.pyc
4
+ *.egg-info/
5
+ .venv/
6
+ dist/
7
+ build/
8
+
9
+ # Node
10
+ node_modules/
11
+ .next/
12
+
13
+ # IDE
14
+ .DS_Store
15
+ .vscode/
16
+ .idea/
17
+
18
+ # Env
19
+ .env
20
+ .env.local
21
+
22
+ # Install script artifacts
23
+ .venv-embedding/
24
+
25
+ # DB
26
+ *.sqlite
27
+ *.db
28
+
29
+ # Logs
30
+ *.log
31
+
32
+ # OS
33
+ Thumbs.db
34
+ .env
35
+ .claude/
@@ -0,0 +1,46 @@
1
+ Metadata-Version: 2.4
2
+ Name: memento-brain-memory
3
+ Version: 0.0.1
4
+ Summary: MCP Memory Server — personal AI memory powered by Memento data
5
+ Project-URL: Homepage, https://github.com/ddong8/memento
6
+ Project-URL: Repository, https://github.com/ddong8/memento
7
+ Project-URL: Issues, https://github.com/ddong8/memento/issues
8
+ Author: ddong8
9
+ License: MIT
10
+ Keywords: ai,claude,knowledge-graph,mcp,memento,memory
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Software Development :: Libraries
15
+ Requires-Python: >=3.10
16
+ Requires-Dist: asyncpg>=0.30
17
+ Requires-Dist: httpx>=0.27
18
+ Requires-Dist: mcp>=1.26
19
+ Requires-Dist: openai>=1.30
20
+ Requires-Dist: pgvector>=0.3
21
+ Requires-Dist: sqlalchemy[asyncio]>=2.0
22
+ Description-Content-Type: text/markdown
23
+
24
+ # Memento MCP Server
25
+
26
+ Personal AI memory powered by your Memento data.
27
+
28
+ ## Usage
29
+
30
+ ```bash
31
+ pip install memento-brain-memory
32
+ memento-memory --db-url postgresql+asyncpg://user:pass@host:port/memento
33
+ ```
34
+
35
+ ## Claude Code Configuration
36
+
37
+ ```json
38
+ {
39
+ "mcpServers": {
40
+ "memento-memory": {
41
+ "command": "memento-memory",
42
+ "args": ["--db-url", "postgresql+asyncpg://postgres:postgres@localhost:5433/memento"]
43
+ }
44
+ }
45
+ }
46
+ ```
@@ -0,0 +1,23 @@
1
+ # Memento MCP Server
2
+
3
+ Personal AI memory powered by your Memento data.
4
+
5
+ ## Usage
6
+
7
+ ```bash
8
+ pip install memento-brain-memory
9
+ memento-memory --db-url postgresql+asyncpg://user:pass@host:port/memento
10
+ ```
11
+
12
+ ## Claude Code Configuration
13
+
14
+ ```json
15
+ {
16
+ "mcpServers": {
17
+ "memento-memory": {
18
+ "command": "memento-memory",
19
+ "args": ["--db-url", "postgresql+asyncpg://postgres:postgres@localhost:5433/memento"]
20
+ }
21
+ }
22
+ }
23
+ ```
@@ -0,0 +1 @@
1
+ """Memento MCP Server — personal AI memory for all tools."""
@@ -0,0 +1,49 @@
1
+ """Entry point for the MCP Memory Server.
2
+
3
+ Usage:
4
+ # Remote mode (recommended — no DB needed, works anywhere):
5
+ memento-memory --server https://report.ihasy.com --token YOUR_JWT_TOKEN
6
+
7
+ # Direct DB mode (local dev / self-hosted):
8
+ memento-memory --db-url postgresql+asyncpg://user:pass@host:port/memento
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import os
15
+ import sys
16
+
17
+
18
+ def main():
19
+ parser = argparse.ArgumentParser(
20
+ description="Memento MCP Server — personal AI memory for all tools",
21
+ )
22
+ parser.add_argument("--server", help="Memento server URL (e.g. https://report.ihasy.com)")
23
+ parser.add_argument("--token", help="JWT token for authentication")
24
+ parser.add_argument("--db-url", help="PostgreSQL connection URL (for direct DB mode)")
25
+ args = parser.parse_args()
26
+
27
+ server_url = args.server or os.environ.get("MEMENTO_SERVER_URL")
28
+ token = args.token or os.environ.get("MEMENTO_SERVER_TOKEN")
29
+ db_url = args.db_url or os.environ.get("MEMENTO_DATABASE_URL")
30
+
31
+ from .server import mcp, init_server
32
+
33
+ if server_url and token:
34
+ init_server(server_url=server_url, token=token)
35
+ elif db_url:
36
+ init_server(db_url=db_url)
37
+ else:
38
+ print("Error: Either --server/--token or --db-url is required.", file=sys.stderr)
39
+ print("\nRemote mode (recommended):", file=sys.stderr)
40
+ print(" memento-memory --server https://report.ihasy.com --token YOUR_TOKEN", file=sys.stderr)
41
+ print("\nDirect DB mode:", file=sys.stderr)
42
+ print(" memento-memory --db-url postgresql+asyncpg://...", file=sys.stderr)
43
+ sys.exit(1)
44
+
45
+ mcp.run(transport="stdio")
46
+
47
+
48
+ if __name__ == "__main__":
49
+ main()
@@ -0,0 +1,150 @@
1
+ """Database connection for MCP server — connects directly to PostgreSQL."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+
7
+ from sqlalchemy import (
8
+ BigInteger, Boolean, Date, DateTime, Float, ForeignKey, Index, Integer,
9
+ String, Text, UniqueConstraint, func,
10
+ )
11
+ from sqlalchemy.dialects.postgresql import JSONB, UUID
12
+ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
13
+ from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
14
+ import uuid
15
+ from datetime import date, datetime
16
+
17
+ try:
18
+ from pgvector.sqlalchemy import Vector
19
+ except ImportError:
20
+ Vector = None
21
+
22
+
23
+ class Base(DeclarativeBase):
24
+ pass
25
+
26
+
27
+ # Minimal model definitions (mirror of server models, read-only)
28
+
29
+ class User(Base):
30
+ __tablename__ = "users"
31
+ id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True)
32
+ email: Mapped[str] = mapped_column(String(320))
33
+ name: Mapped[str | None] = mapped_column(String(255))
34
+ role: Mapped[str] = mapped_column(String(20))
35
+ collector_token: Mapped[str | None] = mapped_column(String(64))
36
+
37
+
38
+ class Machine(Base):
39
+ __tablename__ = "machines"
40
+ id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True)
41
+ name: Mapped[str] = mapped_column(String(255))
42
+ user_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("users.id"))
43
+
44
+
45
+ class Tool(Base):
46
+ __tablename__ = "tools"
47
+ id: Mapped[str] = mapped_column(String(50), primary_key=True)
48
+ display_name: Mapped[str] = mapped_column(String(100))
49
+
50
+
51
+ class Project(Base):
52
+ __tablename__ = "projects"
53
+ id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True)
54
+ slug: Mapped[str] = mapped_column(String(255))
55
+ title: Mapped[str] = mapped_column(String(500))
56
+ tool_id: Mapped[str | None] = mapped_column(ForeignKey("tools.id"))
57
+ source_path: Mapped[str | None] = mapped_column(Text)
58
+
59
+
60
+ class Document(Base):
61
+ __tablename__ = "documents"
62
+ id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True)
63
+ tool_id: Mapped[str] = mapped_column(String(50))
64
+ project_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("projects.id"))
65
+ machine_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("machines.id"))
66
+ relative_path: Mapped[str] = mapped_column(Text)
67
+ category: Mapped[str] = mapped_column(String(50))
68
+ content_type: Mapped[str] = mapped_column(String(50))
69
+ title: Mapped[str | None] = mapped_column(Text)
70
+ content: Mapped[str | None] = mapped_column(Text)
71
+ content_hash: Mapped[str] = mapped_column(String(64))
72
+ file_size_bytes: Mapped[int] = mapped_column(BigInteger)
73
+ metadata_: Mapped[dict] = mapped_column("metadata", JSONB, default=dict)
74
+ ai_summary: Mapped[str | None] = mapped_column(Text)
75
+ synced_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
76
+
77
+
78
+ class ConversationMessage(Base):
79
+ __tablename__ = "conversation_messages"
80
+ id: Mapped[int] = mapped_column(Integer, primary_key=True)
81
+ document_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("documents.id"))
82
+ line_number: Mapped[int] = mapped_column(Integer)
83
+ message_type: Mapped[str | None] = mapped_column(String(50))
84
+ role: Mapped[str | None] = mapped_column(String(20))
85
+ content: Mapped[str | None] = mapped_column(Text)
86
+ metadata_: Mapped[dict | None] = mapped_column("metadata", JSONB)
87
+ timestamp: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
88
+
89
+
90
+ class DailySummary(Base):
91
+ __tablename__ = "daily_summaries"
92
+ id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True)
93
+ summary_date: Mapped[date] = mapped_column(Date)
94
+ tool_id: Mapped[str | None] = mapped_column(String(50))
95
+ title: Mapped[str] = mapped_column(Text)
96
+ summary: Mapped[str] = mapped_column(Text)
97
+ highlights: Mapped[dict | None] = mapped_column(JSONB)
98
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
99
+
100
+
101
+ class DocumentEmbedding(Base):
102
+ __tablename__ = "document_embeddings"
103
+ id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True)
104
+ document_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("documents.id"))
105
+ chunk_index: Mapped[int] = mapped_column(Integer)
106
+ chunk_text: Mapped[str] = mapped_column(Text)
107
+ embedding = mapped_column(Vector(1024) if Vector else Text)
108
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
109
+
110
+
111
+ class KnowledgeEntity(Base):
112
+ __tablename__ = "knowledge_entities"
113
+ id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True)
114
+ user_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("users.id"))
115
+ name: Mapped[str] = mapped_column(Text)
116
+ entity_type: Mapped[str] = mapped_column(String(50))
117
+ summary: Mapped[str | None] = mapped_column(Text)
118
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
119
+ updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
120
+
121
+
122
+ class KnowledgeRelation(Base):
123
+ __tablename__ = "knowledge_relations"
124
+ id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True)
125
+ source_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("knowledge_entities.id"))
126
+ target_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("knowledge_entities.id"))
127
+ relation_type: Mapped[str] = mapped_column(String(50))
128
+ strength: Mapped[float] = mapped_column(Float)
129
+
130
+
131
+ class KnowledgeObservation(Base):
132
+ __tablename__ = "knowledge_observations"
133
+ id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True)
134
+ entity_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("knowledge_entities.id"))
135
+ content: Mapped[str] = mapped_column(Text)
136
+ source_document_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("documents.id"))
137
+ observed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
138
+
139
+
140
+ def get_db_url() -> str:
141
+ return os.environ.get(
142
+ "MEMENTO_DATABASE_URL",
143
+ "postgresql+asyncpg://postgres:postgres@localhost:5433/memento",
144
+ )
145
+
146
+
147
+ def create_engine_and_session(db_url: str | None = None) -> async_sessionmaker[AsyncSession]:
148
+ url = db_url or get_db_url()
149
+ engine = create_async_engine(url, pool_size=5, max_overflow=10)
150
+ return async_sessionmaker(engine, expire_on_commit=False)
@@ -0,0 +1,162 @@
1
+ """Knowledge graph operations — entity context and observation storage."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uuid
6
+ from datetime import datetime, timezone
7
+
8
+ from sqlalchemy import func, select, or_
9
+ from sqlalchemy.ext.asyncio import AsyncSession
10
+
11
+ from .db import (
12
+ Document, KnowledgeEntity, KnowledgeObservation, KnowledgeRelation,
13
+ Machine, Project,
14
+ )
15
+
16
+
17
+ async def get_entity_context(
18
+ db: AsyncSession, project_name: str, user_id: uuid.UUID | None = None,
19
+ ) -> str:
20
+ """Build comprehensive project context from knowledge graph + documents."""
21
+ parts = []
22
+
23
+ # 1. Find the project
24
+ project_q = select(Project).where(
25
+ or_(
26
+ Project.title.ilike(f"%{project_name}%"),
27
+ Project.slug.ilike(f"%{project_name}%"),
28
+ )
29
+ ).limit(1)
30
+ project = (await db.execute(project_q)).scalar_one_or_none()
31
+
32
+ if project:
33
+ parts.append(f"# Project: {project.title}")
34
+ parts.append(f"**Tool**: {project.tool_id}")
35
+ if project.source_path:
36
+ parts.append(f"**Path**: `{project.source_path}`")
37
+
38
+ # Get document stats
39
+ doc_stats = await db.execute(
40
+ select(Document.category, func.count())
41
+ .where(Document.project_id == project.id)
42
+ .group_by(Document.category)
43
+ )
44
+ stats = {r[0]: r[1] for r in doc_stats.all()}
45
+ if stats:
46
+ parts.append("\n## Documents")
47
+ for cat, count in sorted(stats.items()):
48
+ parts.append(f"- {cat}: {count}")
49
+
50
+ # Get recent conversation titles
51
+ recent = await db.execute(
52
+ select(Document.title, Document.synced_at)
53
+ .where(Document.project_id == project.id, Document.category == "conversation")
54
+ .order_by(Document.synced_at.desc())
55
+ .limit(5)
56
+ )
57
+ convos = recent.all()
58
+ if convos:
59
+ parts.append("\n## Recent Conversations")
60
+ for title, synced_at in convos:
61
+ parts.append(f"- {title} ({synced_at.strftime('%Y-%m-%d')})")
62
+
63
+ # Get memory/plan files
64
+ memory_docs = await db.execute(
65
+ select(Document.title, Document.content, Document.category)
66
+ .where(
67
+ Document.project_id == project.id,
68
+ Document.category.in_(["memory", "plan", "identity"]),
69
+ )
70
+ .order_by(Document.synced_at.desc())
71
+ .limit(5)
72
+ )
73
+ for title, content, cat in memory_docs.all():
74
+ parts.append(f"\n## {cat.title()}: {title}")
75
+ parts.append((content or "")[:1000])
76
+
77
+ # 2. Knowledge graph entities matching the project name
78
+ entity_filter = [KnowledgeEntity.name.ilike(f"%{project_name}%")]
79
+ if user_id:
80
+ entity_filter.append(
81
+ or_(KnowledgeEntity.user_id == user_id, KnowledgeEntity.user_id.is_(None))
82
+ )
83
+
84
+ entities = await db.execute(
85
+ select(KnowledgeEntity).where(*entity_filter).limit(10)
86
+ )
87
+ entity_list = entities.scalars().all()
88
+
89
+ if entity_list:
90
+ parts.append("\n## Knowledge Graph")
91
+ for entity in entity_list:
92
+ parts.append(f"\n### {entity.name} ({entity.entity_type})")
93
+ if entity.summary:
94
+ parts.append(entity.summary)
95
+
96
+ # Relations
97
+ rels = await db.execute(
98
+ select(KnowledgeRelation, KnowledgeEntity)
99
+ .join(KnowledgeEntity, KnowledgeRelation.target_id == KnowledgeEntity.id)
100
+ .where(KnowledgeRelation.source_id == entity.id)
101
+ .limit(10)
102
+ )
103
+ for rel, target in rels.all():
104
+ parts.append(f" → {rel.relation_type} → **{target.name}** ({target.entity_type})")
105
+
106
+ # Observations
107
+ obs = await db.execute(
108
+ select(KnowledgeObservation.content, KnowledgeObservation.observed_at)
109
+ .where(KnowledgeObservation.entity_id == entity.id)
110
+ .order_by(KnowledgeObservation.observed_at.desc())
111
+ .limit(5)
112
+ )
113
+ for content, obs_at in obs.all():
114
+ date_str = obs_at.strftime("%Y-%m-%d") if obs_at else ""
115
+ parts.append(f" - [{date_str}] {content}")
116
+
117
+ if not parts:
118
+ return f"No context found for '{project_name}'."
119
+
120
+ return "\n".join(parts)
121
+
122
+
123
+ async def store_observation(
124
+ db: AsyncSession,
125
+ content: str,
126
+ entity_name: str | None = None,
127
+ entity_type: str = "concept",
128
+ user_id: uuid.UUID | None = None,
129
+ ) -> str:
130
+ """Store a new observation, creating the entity if needed."""
131
+ if not entity_name:
132
+ # Auto-extract entity from content (simple heuristic)
133
+ words = content.split()
134
+ entity_name = " ".join(words[:3]) if len(words) > 3 else content[:50]
135
+
136
+ # Find or create entity
137
+ q = select(KnowledgeEntity).where(
138
+ KnowledgeEntity.name == entity_name,
139
+ KnowledgeEntity.entity_type == entity_type,
140
+ )
141
+ if user_id:
142
+ q = q.where(KnowledgeEntity.user_id == user_id)
143
+
144
+ entity = (await db.execute(q)).scalar_one_or_none()
145
+ if not entity:
146
+ entity = KnowledgeEntity(
147
+ user_id=user_id,
148
+ name=entity_name,
149
+ entity_type=entity_type,
150
+ )
151
+ db.add(entity)
152
+ await db.flush()
153
+
154
+ # Add observation
155
+ obs = KnowledgeObservation(
156
+ entity_id=entity.id,
157
+ content=content,
158
+ )
159
+ db.add(obs)
160
+ await db.flush()
161
+
162
+ return f"Stored observation for entity '{entity_name}' ({entity_type}): {content[:100]}"
@@ -0,0 +1,143 @@
1
+ """Remote HTTP client — calls Memento server REST API instead of direct DB access.
2
+
3
+ This lets the MCP server run on any machine without needing PostgreSQL connection.
4
+ Only needs: server URL + user's collector token (JWT or collector_token).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ from datetime import date
11
+
12
+ import httpx
13
+
14
+ logger = logging.getLogger("mcp_memory.remote")
15
+
16
+
17
+ class RemoteClient:
18
+ """HTTP client for Memento server API."""
19
+
20
+ def __init__(self, server_url: str, token: str):
21
+ self.base_url = server_url.rstrip("/")
22
+ self.token = token
23
+ self._jwt: str | None = None
24
+ self._jwt_time: float = 0 # When JWT was obtained
25
+
26
+ async def _ensure_jwt(self) -> str:
27
+ """Get JWT token with auto-renewal (re-exchange after 20 hours)."""
28
+ import time
29
+ # Renew if JWT older than 20 hours (tokens expire at 24h)
30
+ if self._jwt and (time.time() - self._jwt_time) < 72000:
31
+ return self._jwt
32
+ self._jwt = None # Force re-exchange
33
+ async with httpx.AsyncClient(timeout=10) as client:
34
+ # Try as JWT directly
35
+ resp = await client.get(
36
+ f"{self.base_url}/api/auth/me",
37
+ headers={"Authorization": f"Bearer {self.token}"},
38
+ )
39
+ if resp.status_code == 200:
40
+ self._jwt = self.token
41
+ self._jwt_time = time.time()
42
+ return self._jwt
43
+
44
+ # Try token-exchange (collector_token → JWT)
45
+ resp = await client.post(
46
+ f"{self.base_url}/api/auth/token-exchange",
47
+ headers={"X-Collector-Token": self.token},
48
+ )
49
+ if resp.status_code == 200:
50
+ data = resp.json()
51
+ self._jwt = data["access_token"]
52
+ self._jwt_time = time.time()
53
+ logger.info("JWT obtained via token-exchange")
54
+ return self._jwt
55
+
56
+ raise RuntimeError(
57
+ f"Invalid token. Get a valid token from {self.base_url}"
58
+ )
59
+
60
+ async def _get(self, path: str, params: dict | None = None) -> dict | list:
61
+ jwt = await self._ensure_jwt()
62
+ async with httpx.AsyncClient(timeout=30) as client:
63
+ resp = await client.get(
64
+ f"{self.base_url}{path}",
65
+ params=params,
66
+ headers={"Authorization": f"Bearer {jwt}"},
67
+ )
68
+ # Auto-retry on 401 (JWT expired)
69
+ if resp.status_code == 401:
70
+ self._jwt = None
71
+ jwt = await self._ensure_jwt()
72
+ resp = await client.get(
73
+ f"{self.base_url}{path}",
74
+ params=params,
75
+ headers={"Authorization": f"Bearer {jwt}"},
76
+ )
77
+ resp.raise_for_status()
78
+ return resp.json()
79
+
80
+ async def _post(self, path: str, json_data: dict | None = None) -> dict:
81
+ jwt = await self._ensure_jwt()
82
+ async with httpx.AsyncClient(timeout=30) as client:
83
+ resp = await client.post(
84
+ f"{self.base_url}{path}",
85
+ json=json_data,
86
+ headers={"Authorization": f"Bearer {jwt}"},
87
+ )
88
+ if resp.status_code == 401:
89
+ self._jwt = None
90
+ jwt = await self._ensure_jwt()
91
+ resp = await client.post(
92
+ f"{self.base_url}{path}",
93
+ json=json_data,
94
+ headers={"Authorization": f"Bearer {jwt}"},
95
+ )
96
+ resp.raise_for_status()
97
+ return resp.json()
98
+
99
+ # --- Memory search ---
100
+ async def search(self, query: str, limit: int = 5, tool_filter: str | None = None) -> list[dict]:
101
+ params = {"q": query, "limit": limit}
102
+ if tool_filter:
103
+ params["tool"] = tool_filter
104
+ result = await self._get("/api/search", params)
105
+ return result.get("results", []) if isinstance(result, dict) else []
106
+
107
+ # --- Projects ---
108
+ async def list_projects(self) -> list[dict]:
109
+ return await self._get("/api/projects")
110
+
111
+ async def get_project(self, project_id: str) -> dict:
112
+ return await self._get(f"/api/projects/{project_id}")
113
+
114
+ # --- Documents ---
115
+ async def get_document(self, doc_id: str) -> dict:
116
+ return await self._get(f"/api/documents/{doc_id}")
117
+
118
+ # --- Conversations ---
119
+ async def get_conversation_messages(self, doc_id: str, limit: int = 50) -> dict:
120
+ return await self._get(f"/api/conversations/{doc_id}/messages", {"limit": limit})
121
+
122
+ # --- Daily ---
123
+ async def get_daily(self, date_str: str) -> dict:
124
+ from datetime import datetime, timezone, timedelta
125
+ tz = int(datetime.now(timezone.utc).astimezone().utcoffset().total_seconds() // 60)
126
+ return await self._get(f"/api/daily/{date_str}", {"tz_offset": -tz})
127
+
128
+ async def get_daily_dates(self, days: int = 30) -> list[dict]:
129
+ return await self._get("/api/daily", {"days": days})
130
+
131
+ # --- Dashboard ---
132
+ async def get_dashboard(self) -> dict:
133
+ return await self._get("/api/dashboard")
134
+
135
+ # --- Tools ---
136
+ async def get_tools(self) -> list[dict]:
137
+ return await self._get("/api/tools")
138
+
139
+ async def get_tool_files(self, tool_id: str, category: str | None = None) -> list[dict]:
140
+ params = {}
141
+ if category:
142
+ params["category"] = category
143
+ return await self._get(f"/api/tools/{tool_id}/files", params)
@@ -0,0 +1,254 @@
1
+ """Hybrid search engine — semantic (pgvector) + full-text (tsvector) + knowledge graph."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import uuid
7
+ from datetime import datetime, timedelta, timezone
8
+
9
+ from sqlalchemy import func, select, text, or_
10
+ from sqlalchemy.ext.asyncio import AsyncSession
11
+
12
+ from .db import Document, DocumentEmbedding, KnowledgeEntity, KnowledgeObservation, Machine
13
+
14
+ logger = logging.getLogger("mcp_memory.search")
15
+
16
+
17
+ _local_model = None
18
+ _model_lock = None
19
+
20
+
21
+ def _get_local_model():
22
+ """Load BGE-M3 model lazily."""
23
+ global _local_model, _model_lock
24
+ import threading
25
+ if _model_lock is None:
26
+ _model_lock = threading.Lock()
27
+ if _local_model is not None:
28
+ return _local_model
29
+ with _model_lock:
30
+ if _local_model is not None:
31
+ return _local_model
32
+ try:
33
+ from sentence_transformers import SentenceTransformer
34
+ _local_model = SentenceTransformer("BAAI/bge-m3")
35
+ return _local_model
36
+ except Exception:
37
+ return None
38
+
39
+
40
+ async def _get_embedding(query: str) -> list[float] | None:
41
+ """Generate embedding for a query. Tries local BGE-M3 first, then remote API."""
42
+ import asyncio
43
+ # Try local model
44
+ model = _get_local_model()
45
+ if model is not None:
46
+ try:
47
+ embedding = await asyncio.to_thread(
48
+ lambda: model.encode(query, normalize_embeddings=True).tolist()
49
+ )
50
+ return embedding
51
+ except Exception:
52
+ pass
53
+
54
+ # Fallback: remote API
55
+ import os
56
+ api_key = os.environ.get("MEMENTO_EMBEDDING_API_KEY") or os.environ.get("OPENAI_API_KEY")
57
+ base_url = os.environ.get("MEMENTO_EMBEDDING_BASE_URL")
58
+ emb_model = os.environ.get("MEMENTO_EMBEDDING_MODEL", "text-embedding-v4")
59
+ if not api_key or not base_url:
60
+ return None
61
+ try:
62
+ import openai
63
+ client = openai.AsyncOpenAI(api_key=api_key, base_url=base_url)
64
+ response = await client.embeddings.create(input=query, model=emb_model)
65
+ return response.data[0].embedding
66
+ except Exception as e:
67
+ logger.debug("Embedding generation failed: %s", e)
68
+ return None
69
+
70
+
71
+ async def _semantic_search(
72
+ db: AsyncSession, query: str, limit: int, user_machine_ids: list[uuid.UUID] | None,
73
+ tool_filter: str | None, cutoff: datetime | None,
74
+ ) -> list[dict]:
75
+ """Search via pgvector cosine similarity."""
76
+ embedding = await _get_embedding(query)
77
+ if embedding is None:
78
+ return []
79
+
80
+ try:
81
+ from pgvector.sqlalchemy import Vector
82
+ except ImportError:
83
+ return []
84
+
85
+ q = (
86
+ select(
87
+ DocumentEmbedding.chunk_text,
88
+ DocumentEmbedding.document_id,
89
+ Document.title,
90
+ Document.tool_id,
91
+ Document.relative_path,
92
+ Document.synced_at,
93
+ DocumentEmbedding.embedding.cosine_distance(embedding).label("distance"),
94
+ )
95
+ .join(Document, DocumentEmbedding.document_id == Document.id)
96
+ .order_by("distance")
97
+ .limit(limit)
98
+ )
99
+ if user_machine_ids is not None:
100
+ q = q.where(Document.machine_id.in_(user_machine_ids))
101
+ if tool_filter:
102
+ q = q.where(Document.tool_id == tool_filter)
103
+ if cutoff:
104
+ q = q.where(Document.synced_at >= cutoff)
105
+
106
+ result = await db.execute(q)
107
+ return [
108
+ {
109
+ "content": row.chunk_text,
110
+ "title": row.title or row.relative_path,
111
+ "tool_id": row.tool_id,
112
+ "relative_path": row.relative_path,
113
+ "date": row.synced_at.strftime("%Y-%m-%d") if row.synced_at else "",
114
+ "score": 1.0 - row.distance, # Convert distance to similarity
115
+ "source": "semantic",
116
+ }
117
+ for row in result.all()
118
+ ]
119
+
120
+
121
+ async def _fulltext_search(
122
+ db: AsyncSession, query: str, limit: int, user_machine_ids: list[uuid.UUID] | None,
123
+ tool_filter: str | None, cutoff: datetime | None,
124
+ ) -> list[dict]:
125
+ """Search via PostgreSQL LIKE (simple but effective)."""
126
+ pattern = f"%{query}%"
127
+ q = (
128
+ select(Document.title, Document.tool_id, Document.relative_path,
129
+ Document.content, Document.synced_at)
130
+ .where(
131
+ or_(
132
+ Document.content.ilike(pattern),
133
+ Document.title.ilike(pattern),
134
+ )
135
+ )
136
+ .order_by(Document.synced_at.desc())
137
+ .limit(limit)
138
+ )
139
+ if user_machine_ids is not None:
140
+ q = q.where(Document.machine_id.in_(user_machine_ids))
141
+ if tool_filter:
142
+ q = q.where(Document.tool_id == tool_filter)
143
+ if cutoff:
144
+ q = q.where(Document.synced_at >= cutoff)
145
+
146
+ result = await db.execute(q)
147
+ results = []
148
+ for row in result.all():
149
+ # Extract relevant snippet around the match
150
+ content = row.content or ""
151
+ idx = content.lower().find(query.lower())
152
+ if idx >= 0:
153
+ start = max(0, idx - 200)
154
+ end = min(len(content), idx + len(query) + 300)
155
+ snippet = ("..." if start > 0 else "") + content[start:end] + ("..." if end < len(content) else "")
156
+ else:
157
+ snippet = content[:500]
158
+
159
+ results.append({
160
+ "content": snippet,
161
+ "title": row.title or row.relative_path,
162
+ "tool_id": row.tool_id,
163
+ "relative_path": row.relative_path,
164
+ "date": row.synced_at.strftime("%Y-%m-%d") if row.synced_at else "",
165
+ "score": 0.5, # Fixed score for full-text matches
166
+ "source": "fulltext",
167
+ })
168
+ return results
169
+
170
+
171
+ async def _graph_search(
172
+ db: AsyncSession, query: str, limit: int, user_id: uuid.UUID | None,
173
+ ) -> list[dict]:
174
+ """Search via knowledge graph entity matching."""
175
+ # Find matching entities
176
+ q = (
177
+ select(KnowledgeEntity.name, KnowledgeEntity.entity_type, KnowledgeEntity.summary)
178
+ .where(KnowledgeEntity.name.ilike(f"%{query}%"))
179
+ .limit(5)
180
+ )
181
+ if user_id:
182
+ q = q.where(or_(KnowledgeEntity.user_id == user_id, KnowledgeEntity.user_id.is_(None)))
183
+
184
+ entities = await db.execute(q)
185
+ entity_rows = entities.all()
186
+ if not entity_rows:
187
+ return []
188
+
189
+ results = []
190
+ for name, etype, summary in entity_rows:
191
+ # Get recent observations
192
+ obs_q = (
193
+ select(KnowledgeObservation.content, KnowledgeObservation.observed_at)
194
+ .join(KnowledgeEntity, KnowledgeObservation.entity_id == KnowledgeEntity.id)
195
+ .where(KnowledgeEntity.name == name)
196
+ .order_by(KnowledgeObservation.observed_at.desc())
197
+ .limit(3)
198
+ )
199
+ obs_result = await db.execute(obs_q)
200
+ observations = [f"- {r.content}" for r in obs_result.all()]
201
+
202
+ content = f"**{name}** ({etype})\n"
203
+ if summary:
204
+ content += f"{summary}\n"
205
+ if observations:
206
+ content += "\nRecent observations:\n" + "\n".join(observations)
207
+
208
+ results.append({
209
+ "content": content,
210
+ "title": name,
211
+ "tool_id": "knowledge_graph",
212
+ "relative_path": f"entity/{etype}/{name}",
213
+ "date": "",
214
+ "score": 0.7,
215
+ "source": "graph",
216
+ })
217
+ return results
218
+
219
+
220
+ async def hybrid_search(
221
+ db: AsyncSession,
222
+ query: str,
223
+ limit: int = 5,
224
+ tool_filter: str | None = None,
225
+ days: int | None = None,
226
+ user_id: uuid.UUID | None = None,
227
+ ) -> list[dict]:
228
+ """Combined semantic + full-text + graph search with deduplication."""
229
+ cutoff = datetime.now(timezone.utc) - timedelta(days=days) if days else None
230
+
231
+ # Get user's machine IDs for data isolation
232
+ user_machine_ids = None
233
+ if user_id:
234
+ result = await db.execute(select(Machine.id).where(Machine.user_id == user_id))
235
+ user_machine_ids = [r[0] for r in result.all()]
236
+
237
+ # Run all search strategies
238
+ semantic_results = await _semantic_search(db, query, limit * 2, user_machine_ids, tool_filter, cutoff)
239
+ fulltext_results = await _fulltext_search(db, query, limit * 2, user_machine_ids, tool_filter, cutoff)
240
+ graph_results = await _graph_search(db, query, limit, user_id)
241
+
242
+ # Merge and deduplicate by relative_path
243
+ seen = set()
244
+ merged = []
245
+ for r in sorted(semantic_results + fulltext_results + graph_results, key=lambda x: -x["score"]):
246
+ key = r["relative_path"]
247
+ if key in seen:
248
+ continue
249
+ seen.add(key)
250
+ merged.append(r)
251
+ if len(merged) >= limit:
252
+ break
253
+
254
+ return merged
@@ -0,0 +1,415 @@
1
+ """MCP Memory Server — exposes personal AI memory via Model Context Protocol.
2
+
3
+ Two modes:
4
+ - Remote (--server + --token): calls Memento server REST API. No DB needed.
5
+ - Direct (--db-url): connects directly to PostgreSQL. For local dev/self-hosted.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ from datetime import date, datetime, timedelta, timezone
12
+
13
+ from mcp.server.fastmcp import FastMCP
14
+
15
+ logger = logging.getLogger("mcp_memory")
16
+
17
+ mcp = FastMCP(
18
+ "Memento",
19
+ instructions="Personal AI memory — search conversations, recall knowledge, explore project context from your Memento data.",
20
+ )
21
+
22
+ # Initialized on startup
23
+ _remote = None # RemoteClient for HTTP mode
24
+ _session_factory = None # SQLAlchemy session factory for DB mode
25
+
26
+
27
+ def init_server(server_url: str | None = None, token: str | None = None, db_url: str | None = None):
28
+ """Initialize the MCP server. Either (server_url + token) or db_url."""
29
+ global _remote, _session_factory
30
+ if server_url and token:
31
+ from .remote_client import RemoteClient
32
+ _remote = RemoteClient(server_url, token)
33
+ logger.info("MCP Memory Server initialized in remote mode: %s", server_url)
34
+ elif db_url:
35
+ from .db import create_engine_and_session
36
+ _session_factory = create_engine_and_session(db_url)
37
+ logger.info("MCP Memory Server initialized in direct DB mode")
38
+ else:
39
+ raise ValueError("Either --server/--token or --db-url required")
40
+
41
+
42
+ # ---------------------------------------------------------------------------
43
+ # Tools
44
+ # ---------------------------------------------------------------------------
45
+
46
+ @mcp.tool()
47
+ async def memory_search(
48
+ query: str,
49
+ limit: int = 5,
50
+ tool_filter: str | None = None,
51
+ days: int | None = None,
52
+ ) -> str:
53
+ """Search your personal AI memory using semantic + full-text hybrid search.
54
+
55
+ Use this to find past conversations, decisions, solutions, and knowledge
56
+ from all your AI tools (Claude Code, Cursor, Codex, Windsurf, etc.).
57
+
58
+ Args:
59
+ query: Natural language search query
60
+ limit: Max results to return (default 5)
61
+ tool_filter: Optional tool filter (claude_code, codex, cursor, antigravity, openclaw, obsidian)
62
+ days: Optional time filter — only search last N days
63
+ """
64
+ if _remote:
65
+ try:
66
+ results = await _remote.search(query, limit=limit, tool_filter=tool_filter)
67
+ except Exception as e:
68
+ return f"Search failed: {e}"
69
+ if not results:
70
+ return "No matching memories found."
71
+ parts = []
72
+ for i, r in enumerate(results, 1):
73
+ title = r.get('title') or r.get('relative_path', 'Untitled')
74
+ snippet = r.get('snippet', '') or ''
75
+ parts.append(
76
+ f"## Result {i}: {title} ({r.get('tool_id', '')})\n"
77
+ f"**Source**: {r.get('relative_path', '')}\n"
78
+ f"**Date**: {(r.get('synced_at') or '')[:10]}\n\n"
79
+ f"{snippet}\n"
80
+ )
81
+ return "\n---\n\n".join(parts)
82
+
83
+ # Direct DB mode
84
+ from .search import hybrid_search
85
+ async with _session_factory() as db:
86
+ results = await hybrid_search(db, query, limit=limit, tool_filter=tool_filter, days=days)
87
+ if not results:
88
+ return "No matching memories found."
89
+ parts = []
90
+ for i, r in enumerate(results, 1):
91
+ parts.append(
92
+ f"## Result {i}: {r['title']} ({r['tool_id']})\n"
93
+ f"**Source**: {r['relative_path']}\n"
94
+ f"**Date**: {r['date']}\n\n"
95
+ f"{r['content']}\n"
96
+ )
97
+ return "\n---\n\n".join(parts)
98
+
99
+
100
+ @mcp.tool()
101
+ async def memory_recall(
102
+ category: str = "conversation",
103
+ project: str | None = None,
104
+ days: int = 7,
105
+ limit: int = 10,
106
+ date: str | None = None,
107
+ ) -> str:
108
+ """Recall recent memories by category, project, and optional date.
109
+
110
+ Args:
111
+ category: Memory category (conversation, memory, identity, plan, config, learning, skill, note)
112
+ project: Optional project name filter
113
+ days: How far back to look (default 7 days, ignored if date given)
114
+ limit: Max items to return
115
+ date: Optional specific date (YYYY-MM-DD), overrides days
116
+ """
117
+ if _remote:
118
+ # If specific date given, use daily endpoint
119
+ if date:
120
+ try:
121
+ data = await _remote.get_daily(date)
122
+ conversations = data.get("overview", {}).get("conversations", [])
123
+ if not conversations:
124
+ return f"No conversations on {date}."
125
+ parts = [f"# Conversations on {date}\n"]
126
+ for c in conversations[:limit]:
127
+ title = c.get("title") or c.get("id", "")[:8]
128
+ u = c.get("user_messages", 0)
129
+ a = c.get("assistant_messages", 0)
130
+ parts.append(f"- [{c.get('tool_id', '')}] **{title}** ({u}↑ {a}↓)")
131
+ return "\n".join(parts)
132
+ except Exception as e:
133
+ return f"Could not load conversations for {date}: {e}"
134
+
135
+ # Otherwise use search-style: list recent files of category
136
+ try:
137
+ tools = await _remote.get_tools()
138
+ except Exception as e:
139
+ return f"Failed to list tools: {e}"
140
+
141
+ from datetime import datetime as _dt
142
+ cutoff_str = (_dt.utcnow() - timedelta(days=days)).isoformat()
143
+ all_files = []
144
+ for tool in tools:
145
+ try:
146
+ files = await _remote.get_tool_files(tool.get("id", ""), category=category)
147
+ for f in files:
148
+ f["_tool_id"] = tool.get("id", "")
149
+ all_files.append(f)
150
+ except Exception:
151
+ continue
152
+
153
+ # Filter by date and project
154
+ filtered = [f for f in all_files if (f.get("synced_at") or "") >= cutoff_str]
155
+ if project:
156
+ filtered = [f for f in filtered if project.lower() in (f.get("relative_path") or "").lower()]
157
+
158
+ # Sort by synced_at desc
159
+ filtered.sort(key=lambda f: f.get("synced_at", ""), reverse=True)
160
+
161
+ if not filtered:
162
+ return f"No {category} memories in last {days} days."
163
+
164
+ parts = [f"# Recent {category} (last {days} days)\n"]
165
+ for f in filtered[:limit]:
166
+ title = f.get("title") or f.get("relative_path", "")
167
+ d = (f.get("synced_at") or "")[:10]
168
+ parts.append(f"- [{f['_tool_id']}] **{title}** — {d}")
169
+ return "\n".join(parts)
170
+
171
+ # Direct DB mode
172
+ from sqlalchemy import select
173
+ from .db import Document, Project
174
+ async with _session_factory() as db:
175
+ cutoff = datetime.now(timezone.utc) - timedelta(days=days)
176
+ q = (
177
+ select(Document.title, Document.tool_id, Document.relative_path,
178
+ Document.content, Document.synced_at)
179
+ .where(Document.category == category, Document.synced_at >= cutoff)
180
+ .order_by(Document.synced_at.desc())
181
+ .limit(limit)
182
+ )
183
+ if project:
184
+ q = q.join(Project, Document.project_id == Project.id).where(
185
+ Project.title.ilike(f"%{project}%")
186
+ )
187
+ result = await db.execute(q)
188
+ rows = result.all()
189
+ if not rows:
190
+ return f"No {category} memories found in the last {days} days."
191
+ parts = []
192
+ for title, tool_id, path, content, synced_at in rows:
193
+ preview = (content or "")[:500]
194
+ parts.append(
195
+ f"### {title or path} ({tool_id})\n"
196
+ f"*{synced_at.strftime('%Y-%m-%d %H:%M')}*\n\n"
197
+ f"{preview}{'...' if len(content or '') > 500 else ''}\n"
198
+ )
199
+ return "\n---\n\n".join(parts)
200
+
201
+
202
+ @mcp.tool()
203
+ async def memory_context(project_name: str) -> str:
204
+ """Get comprehensive project context — recent conversations, plans, and memory files.
205
+
206
+ Use this when starting work on a project to load all relevant context.
207
+
208
+ Args:
209
+ project_name: Project name to look up
210
+ """
211
+ if _remote:
212
+ projects = await _remote.list_projects()
213
+ matched = [p for p in projects if project_name.lower() in (p.get("title", "") or "").lower()]
214
+ if not matched:
215
+ return f"No project found matching '{project_name}'."
216
+ project = await _remote.get_project(matched[0]["id"])
217
+ parts = [f"# Project: {project.get('title', project_name)}"]
218
+ parts.append(f"**Tool**: {project.get('tool_id', '')}")
219
+ if project.get("source_path"):
220
+ parts.append(f"**Path**: `{project['source_path']}`")
221
+ docs = project.get("documents", [])
222
+ if docs:
223
+ parts.append(f"\n## Documents ({len(docs)})")
224
+ for d in docs[:10]:
225
+ parts.append(f"- [{d.get('category', '')}] {d.get('title', d.get('relative_path', ''))}")
226
+ return "\n".join(parts)
227
+
228
+ # Direct DB mode
229
+ from .graph import get_entity_context
230
+ async with _session_factory() as db:
231
+ return await get_entity_context(db, project_name)
232
+
233
+
234
+ @mcp.tool()
235
+ async def memory_store(
236
+ content: str,
237
+ entity_name: str | None = None,
238
+ entity_type: str = "concept",
239
+ ) -> str:
240
+ """Store a new observation or fact in your personal memory.
241
+
242
+ Use this to save important decisions, learnings, or context for later recall.
243
+
244
+ Args:
245
+ content: The fact or observation to remember
246
+ entity_name: Optional entity this relates to (e.g. project name, technology)
247
+ entity_type: Entity type (project/tool/concept/person/technology)
248
+ """
249
+ if _remote:
250
+ return f"Noted: {content[:200]}... (remote storage coming soon)"
251
+
252
+ from .graph import store_observation
253
+ async with _session_factory() as db:
254
+ result = await store_observation(db, content, entity_name=entity_name, entity_type=entity_type)
255
+ await db.commit()
256
+ return result
257
+
258
+
259
+ @mcp.tool()
260
+ async def daily_summary(date_str: str | None = None) -> str:
261
+ """Get daily activity summary for a specific date.
262
+
263
+ Args:
264
+ date_str: Date in YYYY-MM-DD format (default: today)
265
+ """
266
+ target = date_str or date.today().isoformat()
267
+
268
+ if _remote:
269
+ try:
270
+ data = await _remote.get_daily(target)
271
+ total = data.get("total_messages", 0)
272
+ if total == 0:
273
+ return f"No activity recorded on {target}."
274
+
275
+ tool_stats = data.get("overview", {}).get("tool_stats", {})
276
+ conversations = data.get("overview", {}).get("conversations", [])
277
+ summaries = data.get("summaries", [])
278
+
279
+ parts = [f"# Activity Summary — {target}", f"\n**Total messages**: {total}\n"]
280
+
281
+ # Tool breakdown
282
+ parts.append("## Tools")
283
+ for tool, count in sorted(tool_stats.items(), key=lambda x: -x[1]):
284
+ parts.append(f"- **{tool}**: {count} messages")
285
+
286
+ # Top conversations (real titles, not just counts)
287
+ if conversations:
288
+ parts.append("\n## Top Conversations")
289
+ for c in conversations[:10]:
290
+ title = c.get("title") or c.get("id", "")[:8]
291
+ u = c.get("user_messages", 0)
292
+ a = c.get("assistant_messages", 0)
293
+ parts.append(f"- [{c.get('tool_id', '')}] **{title}** ({u}↑ {a}↓)")
294
+
295
+ # AI summary if exists
296
+ if summaries:
297
+ for s in summaries:
298
+ parts.append(f"\n## {s.get('title', 'AI Summary')}\n{s.get('summary', '')}")
299
+ else:
300
+ parts.append("\n*No AI summary yet. Generate one at https://report.ihasy.com/daily/" + target + "*")
301
+
302
+ return "\n".join(parts)
303
+ except Exception as e:
304
+ return f"Could not load daily summary for {target}: {e}"
305
+
306
+ # Direct DB mode
307
+ from sqlalchemy import select, func, cast, Date as SqlDate
308
+ from .db import DailySummary, ConversationMessage, Document
309
+ target_date = date.fromisoformat(target)
310
+ async with _session_factory() as db:
311
+ result = await db.execute(
312
+ select(DailySummary).where(
313
+ DailySummary.summary_date == target_date, DailySummary.tool_id.is_(None)
314
+ )
315
+ )
316
+ summary = result.scalar_one_or_none()
317
+ if summary:
318
+ return f"# AI Daily Summary — {target}\n\n{summary.summary}"
319
+
320
+ tz_cst = timezone(timedelta(hours=8))
321
+ day_start = datetime(target_date.year, target_date.month, target_date.day, tzinfo=tz_cst)
322
+ day_end = day_start + timedelta(days=1)
323
+ msg_result = await db.execute(
324
+ select(Document.tool_id, func.count())
325
+ .join(ConversationMessage, ConversationMessage.document_id == Document.id)
326
+ .where(ConversationMessage.timestamp >= day_start, ConversationMessage.timestamp < day_end,
327
+ ConversationMessage.role.in_(["user", "assistant"]))
328
+ .group_by(Document.tool_id)
329
+ )
330
+ stats = {r[0]: r[1] for r in msg_result.all()}
331
+ if not stats:
332
+ return f"No activity recorded on {target}."
333
+ total = sum(stats.values())
334
+ lines = [f"# Activity Summary — {target}", f"Total messages: {total}\n"]
335
+ for tool, count in sorted(stats.items(), key=lambda x: -x[1]):
336
+ lines.append(f"- **{tool}**: {count} messages")
337
+ return "\n".join(lines)
338
+
339
+
340
+ # ---------------------------------------------------------------------------
341
+ # Resources
342
+ # ---------------------------------------------------------------------------
343
+
344
+ @mcp.resource("memory://projects")
345
+ async def list_projects() -> str:
346
+ """List all projects with document counts."""
347
+ if _remote:
348
+ projects = await _remote.list_projects()
349
+ if not projects:
350
+ return "No projects found."
351
+ lines = ["# Projects\n"]
352
+ for p in projects[:50]:
353
+ lines.append(f"- **{p.get('title', '')}** ({p.get('tool_id', '')}) — {p.get('document_count', 0)} files")
354
+ return "\n".join(lines)
355
+
356
+ from sqlalchemy import select, func
357
+ from .db import Project, Document
358
+ async with _session_factory() as db:
359
+ result = await db.execute(
360
+ select(Project.title, Project.tool_id, Project.source_path,
361
+ func.count(Document.id).label("doc_count"))
362
+ .outerjoin(Document, Document.project_id == Project.id)
363
+ .group_by(Project.id)
364
+ .order_by(func.count(Document.id).desc())
365
+ .limit(50)
366
+ )
367
+ rows = result.all()
368
+ if not rows:
369
+ return "No projects found."
370
+ lines = ["# Projects\n"]
371
+ for title, tool_id, source_path, count in rows:
372
+ lines.append(f"- **{title}** ({tool_id}) — {count} files — `{source_path or ''}`")
373
+ return "\n".join(lines)
374
+
375
+
376
+ @mcp.resource("memory://projects/{name}")
377
+ async def get_project(name: str) -> str:
378
+ """Get project details."""
379
+ return await memory_context(name)
380
+
381
+
382
+ @mcp.resource("memory://identity/{tool}")
383
+ async def get_identity(tool: str) -> str:
384
+ """Get tool identity files (AGENTS.md, SOUL.md, etc.)."""
385
+ if _remote:
386
+ files = await _remote.get_tool_files(tool, category="identity")
387
+ if not files:
388
+ return f"No identity files found for {tool}."
389
+ parts = []
390
+ for f in files:
391
+ doc = await _remote.get_document(f["id"])
392
+ parts.append(f"## {doc.get('title', '')}\n\n{doc.get('content', '(empty)')}")
393
+ return "\n\n---\n\n".join(parts)
394
+
395
+ from sqlalchemy import select
396
+ from .db import Document
397
+ async with _session_factory() as db:
398
+ result = await db.execute(
399
+ select(Document.title, Document.content)
400
+ .where(Document.tool_id == tool, Document.category == "identity")
401
+ .order_by(Document.synced_at.desc())
402
+ )
403
+ rows = result.all()
404
+ if not rows:
405
+ return f"No identity files found for {tool}."
406
+ parts = []
407
+ for title, content in rows:
408
+ parts.append(f"## {title}\n\n{content or '(empty)'}")
409
+ return "\n\n---\n\n".join(parts)
410
+
411
+
412
+ @mcp.resource("memory://daily/{date_str}")
413
+ async def get_daily(date_str: str) -> str:
414
+ """Get daily report for a specific date."""
415
+ return await daily_summary(date_str)
@@ -0,0 +1,41 @@
1
+ [project]
2
+ name = "memento-brain-memory"
3
+ version = "0.0.1"
4
+ description = "MCP Memory Server — personal AI memory powered by Memento data"
5
+ readme = "README.md"
6
+ license = {text = "MIT"}
7
+ requires-python = ">=3.10"
8
+ authors = [{name = "ddong8"}]
9
+ keywords = ["memento", "mcp", "memory", "ai", "claude", "knowledge-graph"]
10
+ classifiers = [
11
+ "Development Status :: 4 - Beta",
12
+ "License :: OSI Approved :: MIT License",
13
+ "Programming Language :: Python :: 3",
14
+ "Topic :: Software Development :: Libraries",
15
+ ]
16
+ dependencies = [
17
+ "mcp>=1.26",
18
+ "asyncpg>=0.30",
19
+ "sqlalchemy[asyncio]>=2.0",
20
+ "pgvector>=0.3",
21
+ "openai>=1.30",
22
+ "httpx>=0.27",
23
+ ]
24
+
25
+ [project.urls]
26
+ Homepage = "https://github.com/ddong8/memento"
27
+ Repository = "https://github.com/ddong8/memento"
28
+ Issues = "https://github.com/ddong8/memento/issues"
29
+
30
+ [project.scripts]
31
+ # Primary — matches pkg name
32
+ memento-brain-memory = "mcp_server.__main__:main"
33
+ # Short alias — back-compat and convenience
34
+ memento-memory = "mcp_server.__main__:main"
35
+
36
+ [build-system]
37
+ requires = ["hatchling"]
38
+ build-backend = "hatchling.build"
39
+
40
+ [tool.hatch.build.targets.wheel]
41
+ packages = ["mcp_server"]