nate nowack commited on
Commit
33bd6fe
·
unverified ·
2 Parent(s): 8057dc17dfb24b

Merge pull request #49 from jlowin/fs

Browse files
Files changed (4) hide show
  1. .gitignore +1 -1
  2. examples/memory.py +346 -0
  3. src/fastmcp/cli/cli.py +7 -6
  4. uv.lock +1 -1
.gitignore CHANGED
@@ -16,4 +16,4 @@ src/fastmcp/_version.py
16
 
17
  # editors
18
  .cursorrules
19
- .vscode/
 
16
 
17
  # editors
18
  .cursorrules
19
+ .vscode/
examples/memory.py ADDED
@@ -0,0 +1,346 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # /// script
2
+ # dependencies = ["pydantic-ai-slim[openai]", "asyncpg", "numpy", "pgvector", "fastmcp"]
3
+ # ///
4
+
5
+ # uv pip install 'pydantic-ai-slim[openai]' asyncpg numpy pgvector fastmcp
6
+
7
+ """
8
+ Recursive memory system inspired by the human brain's clustering of memories.
9
+ Uses OpenAI's 'text-embedding-3-small' model and pgvector for efficient similarity search.
10
+ """
11
+
12
+ import asyncio
13
+ import math
14
+ import os
15
+ from dataclasses import dataclass
16
+ from datetime import datetime, timezone
17
+ from pathlib import Path
18
+ from typing import Annotated, Self
19
+
20
+ import asyncpg
21
+ import numpy as np
22
+ from openai import AsyncOpenAI
23
+ from pgvector.asyncpg import register_vector # Import register_vector
24
+ from pydantic import BaseModel, Field
25
+ from pydantic_ai import Agent
26
+
27
+ from fastmcp import FastMCP
28
+
29
+ MAX_DEPTH = 5
30
+ SIMILARITY_THRESHOLD = 0.7
31
+ DECAY_FACTOR = 0.99
32
+ REINFORCEMENT_FACTOR = 1.1
33
+
34
+ DEFAULT_LLM_MODEL = "openai:gpt-4o"
35
+ DEFAULT_EMBEDDING_MODEL = "text-embedding-3-small"
36
+
37
+ mcp = FastMCP(
38
+ "memory",
39
+ dependencies=[
40
+ "pydantic-ai-slim[openai]",
41
+ "asyncpg",
42
+ "numpy",
43
+ "pgvector",
44
+ ],
45
+ )
46
+
47
+ DB_DSN = "postgresql://postgres:postgres@localhost:54320/memory_db"
48
+ # reset memory with rm ~/.fastmcp/{USER}/memory/*
49
+ PROFILE_DIR = (
50
+ Path.home() / ".fastmcp" / os.environ.get("USER", "anon") / "memory"
51
+ ).resolve()
52
+ PROFILE_DIR.mkdir(parents=True, exist_ok=True)
53
+
54
+
55
+ def cosine_similarity(a: list[float], b: list[float]) -> float:
56
+ a_array = np.array(a, dtype=np.float64)
57
+ b_array = np.array(b, dtype=np.float64)
58
+ return np.dot(a_array, b_array) / (
59
+ np.linalg.norm(a_array) * np.linalg.norm(b_array)
60
+ )
61
+
62
+
63
+ async def do_ai[T](
64
+ user_prompt: str,
65
+ system_prompt: str,
66
+ result_type: type[T] | Annotated,
67
+ deps=None,
68
+ ) -> T:
69
+ agent = Agent(
70
+ DEFAULT_LLM_MODEL,
71
+ system_prompt=system_prompt,
72
+ result_type=result_type,
73
+ )
74
+ result = await agent.run(user_prompt, deps=deps)
75
+ return result.data
76
+
77
+
78
+ @dataclass
79
+ class Deps:
80
+ openai: AsyncOpenAI
81
+ pool: asyncpg.Pool
82
+
83
+
84
+ async def get_db_pool() -> asyncpg.Pool:
85
+ async def init(conn):
86
+ await conn.execute("CREATE EXTENSION IF NOT EXISTS vector;")
87
+ await register_vector(conn)
88
+
89
+ pool = await asyncpg.create_pool(DB_DSN, init=init)
90
+ return pool
91
+
92
+
93
+ class MemoryNode(BaseModel):
94
+ id: int | None = None
95
+ content: str
96
+ summary: str = ""
97
+ importance: float = 1.0
98
+ access_count: int = 0
99
+ timestamp: float = Field(
100
+ default_factory=lambda: datetime.now(timezone.utc).timestamp()
101
+ )
102
+ embedding: list[float]
103
+
104
+ @classmethod
105
+ async def from_content(cls, content: str, deps: Deps):
106
+ embedding = await get_embedding(content, deps)
107
+ return cls(content=content, embedding=embedding)
108
+
109
+ async def save(self, deps: Deps):
110
+ async with deps.pool.acquire() as conn:
111
+ if self.id is None:
112
+ result = await conn.fetchrow(
113
+ """
114
+ INSERT INTO memories (content, summary, importance, access_count, timestamp, embedding)
115
+ VALUES ($1, $2, $3, $4, $5, $6)
116
+ RETURNING id
117
+ """,
118
+ self.content,
119
+ self.summary,
120
+ self.importance,
121
+ self.access_count,
122
+ self.timestamp,
123
+ self.embedding,
124
+ )
125
+ self.id = result["id"]
126
+ else:
127
+ await conn.execute(
128
+ """
129
+ UPDATE memories
130
+ SET content = $1, summary = $2, importance = $3,
131
+ access_count = $4, timestamp = $5, embedding = $6
132
+ WHERE id = $7
133
+ """,
134
+ self.content,
135
+ self.summary,
136
+ self.importance,
137
+ self.access_count,
138
+ self.timestamp,
139
+ self.embedding,
140
+ self.id,
141
+ )
142
+
143
+ async def merge_with(self, other: Self, deps: Deps):
144
+ self.content = await do_ai(
145
+ f"{self.content}\n\n{other.content}",
146
+ "Combine the following two texts into a single, coherent text.",
147
+ str,
148
+ deps,
149
+ )
150
+ self.importance += other.importance
151
+ self.access_count += other.access_count
152
+ self.embedding = [(a + b) / 2 for a, b in zip(self.embedding, other.embedding)]
153
+ self.summary = await do_ai(
154
+ self.content, "Summarize the following text concisely.", str, deps
155
+ )
156
+ await self.save(deps)
157
+ # Delete the merged node from the database
158
+ if other.id is not None:
159
+ await delete_memory(other.id, deps)
160
+
161
+ def get_effective_importance(self):
162
+ return self.importance * (1 + math.log(self.access_count + 1))
163
+
164
+
165
+ async def get_embedding(text: str, deps: Deps) -> list[float]:
166
+ embedding_response = await deps.openai.embeddings.create(
167
+ input=text,
168
+ model=DEFAULT_EMBEDDING_MODEL,
169
+ )
170
+ return embedding_response.data[0].embedding
171
+
172
+
173
+ async def delete_memory(memory_id: int, deps: Deps):
174
+ async with deps.pool.acquire() as conn:
175
+ await conn.execute("DELETE FROM memories WHERE id = $1", memory_id)
176
+
177
+
178
+ async def add_memory(content: str, deps: Deps):
179
+ new_memory = await MemoryNode.from_content(content, deps)
180
+ await new_memory.save(deps)
181
+
182
+ similar_memories = await find_similar_memories(new_memory.embedding, deps)
183
+ for memory in similar_memories:
184
+ if memory.id != new_memory.id:
185
+ await new_memory.merge_with(memory, deps)
186
+
187
+ await update_importance(new_memory.embedding, deps)
188
+
189
+ await prune_memories(deps)
190
+
191
+ return f"Remembered: {content}"
192
+
193
+
194
+ async def find_similar_memories(embedding: list[float], deps: Deps) -> list[MemoryNode]:
195
+ async with deps.pool.acquire() as conn:
196
+ rows = await conn.fetch(
197
+ """
198
+ SELECT id, content, summary, importance, access_count, timestamp, embedding
199
+ FROM memories
200
+ ORDER BY embedding <-> $1
201
+ LIMIT 5
202
+ """,
203
+ embedding,
204
+ )
205
+ memories = [
206
+ MemoryNode(
207
+ id=row["id"],
208
+ content=row["content"],
209
+ summary=row["summary"],
210
+ importance=row["importance"],
211
+ access_count=row["access_count"],
212
+ timestamp=row["timestamp"],
213
+ embedding=row["embedding"],
214
+ )
215
+ for row in rows
216
+ ]
217
+ return memories
218
+
219
+
220
+ async def update_importance(user_embedding: list[float], deps: Deps):
221
+ async with deps.pool.acquire() as conn:
222
+ rows = await conn.fetch(
223
+ "SELECT id, importance, access_count, embedding FROM memories"
224
+ )
225
+ for row in rows:
226
+ memory_embedding = row["embedding"]
227
+ similarity = cosine_similarity(user_embedding, memory_embedding)
228
+ if similarity > SIMILARITY_THRESHOLD:
229
+ new_importance = row["importance"] * REINFORCEMENT_FACTOR
230
+ new_access_count = row["access_count"] + 1
231
+ else:
232
+ new_importance = row["importance"] * DECAY_FACTOR
233
+ new_access_count = row["access_count"]
234
+ await conn.execute(
235
+ """
236
+ UPDATE memories
237
+ SET importance = $1, access_count = $2
238
+ WHERE id = $3
239
+ """,
240
+ new_importance,
241
+ new_access_count,
242
+ row["id"],
243
+ )
244
+
245
+
246
+ async def prune_memories(deps: Deps):
247
+ async with deps.pool.acquire() as conn:
248
+ rows = await conn.fetch(
249
+ """
250
+ SELECT id, importance, access_count
251
+ FROM memories
252
+ ORDER BY importance DESC
253
+ OFFSET $1
254
+ """,
255
+ MAX_DEPTH,
256
+ )
257
+ for row in rows:
258
+ await conn.execute("DELETE FROM memories WHERE id = $1", row["id"])
259
+
260
+
261
+ async def display_memory_tree(deps: Deps) -> str:
262
+ async with deps.pool.acquire() as conn:
263
+ rows = await conn.fetch(
264
+ """
265
+ SELECT content, summary, importance, access_count
266
+ FROM memories
267
+ ORDER BY importance DESC
268
+ LIMIT $1
269
+ """,
270
+ MAX_DEPTH,
271
+ )
272
+ result = ""
273
+ for row in rows:
274
+ effective_importance = row["importance"] * (
275
+ 1 + math.log(row["access_count"] + 1)
276
+ )
277
+ summary = row["summary"] or row["content"]
278
+ result += f"- {summary} (Importance: {effective_importance:.2f})\n"
279
+ return result
280
+
281
+
282
+ @mcp.tool()
283
+ async def remember(
284
+ contents: list[str] = Field(
285
+ description="List of observations or memories to store"
286
+ ),
287
+ ):
288
+ deps = Deps(openai=AsyncOpenAI(), pool=await get_db_pool())
289
+ try:
290
+ return "\n".join(
291
+ await asyncio.gather(*[add_memory(content, deps) for content in contents])
292
+ )
293
+ finally:
294
+ await deps.pool.close()
295
+
296
+
297
+ @mcp.tool()
298
+ async def read_profile() -> str:
299
+ deps = Deps(openai=AsyncOpenAI(), pool=await get_db_pool())
300
+ profile = await display_memory_tree(deps)
301
+ await deps.pool.close()
302
+ return profile
303
+
304
+
305
+ async def initialize_database():
306
+ pool = await asyncpg.create_pool(
307
+ "postgresql://postgres:postgres@localhost:54320/postgres"
308
+ )
309
+ try:
310
+ async with pool.acquire() as conn:
311
+ await conn.execute("""
312
+ SELECT pg_terminate_backend(pg_stat_activity.pid)
313
+ FROM pg_stat_activity
314
+ WHERE pg_stat_activity.datname = 'memory_db'
315
+ AND pid <> pg_backend_pid();
316
+ """)
317
+ await conn.execute("DROP DATABASE IF EXISTS memory_db;")
318
+ await conn.execute("CREATE DATABASE memory_db;")
319
+ finally:
320
+ await pool.close()
321
+
322
+ pool = await asyncpg.create_pool(DB_DSN)
323
+ try:
324
+ async with pool.acquire() as conn:
325
+ await conn.execute("CREATE EXTENSION IF NOT EXISTS vector;")
326
+
327
+ await register_vector(conn)
328
+
329
+ await conn.execute("""
330
+ CREATE TABLE IF NOT EXISTS memories (
331
+ id SERIAL PRIMARY KEY,
332
+ content TEXT NOT NULL,
333
+ summary TEXT,
334
+ importance REAL NOT NULL,
335
+ access_count INT NOT NULL,
336
+ timestamp DOUBLE PRECISION NOT NULL,
337
+ embedding vector(1536) NOT NULL
338
+ );
339
+ CREATE INDEX IF NOT EXISTS idx_memories_embedding ON memories USING hnsw (embedding vector_l2_ops);
340
+ """)
341
+ finally:
342
+ await pool.close()
343
+
344
+
345
+ if __name__ == "__main__":
346
+ asyncio.run(initialize_database())
src/fastmcp/cli/cli.py CHANGED
@@ -6,11 +6,11 @@ import os
6
  import subprocess
7
  import sys
8
  from pathlib import Path
9
- from typing import Optional, Tuple, Dict
10
 
 
11
  import typer
12
  from typing_extensions import Annotated
13
- import dotenv
14
 
15
  from fastmcp.cli import claude
16
  from fastmcp.utilities.logging import get_logger
@@ -425,10 +425,11 @@ def install(
425
  # Load from .env file if specified
426
  if env_file:
427
  try:
428
- env_values = dotenv.dotenv_values(env_file)
429
- env_dict.update(
430
- (k, str(v)) for k, v in env_values.items() if v is not None
431
- )
 
432
  except Exception as e:
433
  logger.error(f"Failed to load .env file: {e}")
434
  sys.exit(1)
 
6
  import subprocess
7
  import sys
8
  from pathlib import Path
9
+ from typing import Dict, Optional, Tuple
10
 
11
+ import dotenv
12
  import typer
13
  from typing_extensions import Annotated
 
14
 
15
  from fastmcp.cli import claude
16
  from fastmcp.utilities.logging import get_logger
 
425
  # Load from .env file if specified
426
  if env_file:
427
  try:
428
+ env_dict |= {
429
+ k: v
430
+ for k, v in dotenv.dotenv_values(env_file).items()
431
+ if v is not None
432
+ }
433
  except Exception as e:
434
  logger.error(f"Failed to load .env file: {e}")
435
  sys.exit(1)
uv.lock CHANGED
@@ -228,7 +228,7 @@ wheels = [
228
 
229
  [[package]]
230
  name = "fastmcp"
231
- version = "0.3.6.dev5+g6a13ab9.d20241203"
232
  source = { editable = "." }
233
  dependencies = [
234
  { name = "httpx" },
 
228
 
229
  [[package]]
230
  name = "fastmcp"
231
+ version = "0.3.6.dev8+g3b5ae20"
232
  source = { editable = "." }
233
  dependencies = [
234
  { name = "httpx" },