yourmemory 1.4.73__tar.gz → 1.4.76__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.
Files changed (60) hide show
  1. {yourmemory-1.4.73 → yourmemory-1.4.76}/PKG-INFO +1 -1
  2. {yourmemory-1.4.73 → yourmemory-1.4.76}/pyproject.toml +1 -1
  3. {yourmemory-1.4.73 → yourmemory-1.4.76}/src/app.py +59 -1
  4. {yourmemory-1.4.73 → yourmemory-1.4.76}/src/db/migrate.py +63 -0
  5. yourmemory-1.4.76/src/routes/compact.py +53 -0
  6. yourmemory-1.4.76/src/routes/dsar.py +197 -0
  7. yourmemory-1.4.76/src/services/compaction.py +292 -0
  8. {yourmemory-1.4.73 → yourmemory-1.4.76}/yourmemory.egg-info/PKG-INFO +1 -1
  9. {yourmemory-1.4.73 → yourmemory-1.4.76}/yourmemory.egg-info/SOURCES.txt +3 -0
  10. {yourmemory-1.4.73 → yourmemory-1.4.76}/LICENSE +0 -0
  11. {yourmemory-1.4.73 → yourmemory-1.4.76}/README.md +0 -0
  12. {yourmemory-1.4.73 → yourmemory-1.4.76}/memory_mcp.py +0 -0
  13. {yourmemory-1.4.73 → yourmemory-1.4.76}/setup.cfg +0 -0
  14. {yourmemory-1.4.73 → yourmemory-1.4.76}/src/__init__.py +0 -0
  15. {yourmemory-1.4.73 → yourmemory-1.4.76}/src/db/connection.py +0 -0
  16. {yourmemory-1.4.73 → yourmemory-1.4.76}/src/db/duckdb_schema.sql +0 -0
  17. {yourmemory-1.4.73 → yourmemory-1.4.76}/src/db/schema.sql +0 -0
  18. {yourmemory-1.4.73 → yourmemory-1.4.76}/src/db/sqlite_schema.sql +0 -0
  19. {yourmemory-1.4.73 → yourmemory-1.4.76}/src/graph/__init__.py +0 -0
  20. {yourmemory-1.4.73 → yourmemory-1.4.76}/src/graph/backend.py +0 -0
  21. {yourmemory-1.4.73 → yourmemory-1.4.76}/src/graph/graph_store.py +0 -0
  22. {yourmemory-1.4.73 → yourmemory-1.4.76}/src/graph/neo4j_backend.py +0 -0
  23. {yourmemory-1.4.73 → yourmemory-1.4.76}/src/graph/networkx_backend.py +0 -0
  24. {yourmemory-1.4.73 → yourmemory-1.4.76}/src/graph/svo_extract.py +0 -0
  25. {yourmemory-1.4.73 → yourmemory-1.4.76}/src/hook_templates/__init__.py +0 -0
  26. {yourmemory-1.4.73 → yourmemory-1.4.76}/src/hook_templates/yourmemory_observe.py +0 -0
  27. {yourmemory-1.4.73 → yourmemory-1.4.76}/src/hook_templates/yourmemory_recall.py +0 -0
  28. {yourmemory-1.4.73 → yourmemory-1.4.76}/src/hook_templates/yourmemory_recall.sh +0 -0
  29. {yourmemory-1.4.73 → yourmemory-1.4.76}/src/hook_templates/yourmemory_server.py +0 -0
  30. {yourmemory-1.4.73 → yourmemory-1.4.76}/src/hook_templates/yourmemory_store.py +0 -0
  31. {yourmemory-1.4.73 → yourmemory-1.4.76}/src/hook_templates/yourmemory_user.sh +0 -0
  32. {yourmemory-1.4.73 → yourmemory-1.4.76}/src/jobs/decay_job.py +0 -0
  33. {yourmemory-1.4.73 → yourmemory-1.4.76}/src/routes/__init__.py +0 -0
  34. {yourmemory-1.4.73 → yourmemory-1.4.76}/src/routes/agents.py +0 -0
  35. {yourmemory-1.4.73 → yourmemory-1.4.76}/src/routes/audit.py +0 -0
  36. {yourmemory-1.4.73 → yourmemory-1.4.76}/src/routes/graph_viz.py +0 -0
  37. {yourmemory-1.4.73 → yourmemory-1.4.76}/src/routes/memories.py +0 -0
  38. {yourmemory-1.4.73 → yourmemory-1.4.76}/src/routes/proxy.py +0 -0
  39. {yourmemory-1.4.73 → yourmemory-1.4.76}/src/routes/retrieve.py +0 -0
  40. {yourmemory-1.4.73 → yourmemory-1.4.76}/src/routes/ui.py +0 -0
  41. {yourmemory-1.4.73 → yourmemory-1.4.76}/src/services/__init__.py +0 -0
  42. {yourmemory-1.4.73 → yourmemory-1.4.76}/src/services/agent_registry.py +0 -0
  43. {yourmemory-1.4.73 → yourmemory-1.4.76}/src/services/api_keys.py +0 -0
  44. {yourmemory-1.4.73 → yourmemory-1.4.76}/src/services/audit.py +0 -0
  45. {yourmemory-1.4.73 → yourmemory-1.4.76}/src/services/decay.py +0 -0
  46. {yourmemory-1.4.73 → yourmemory-1.4.76}/src/services/embed.py +0 -0
  47. {yourmemory-1.4.73 → yourmemory-1.4.76}/src/services/extract.py +0 -0
  48. {yourmemory-1.4.73 → yourmemory-1.4.76}/src/services/extract_fallback.py +0 -0
  49. {yourmemory-1.4.73 → yourmemory-1.4.76}/src/services/resolve.py +0 -0
  50. {yourmemory-1.4.73 → yourmemory-1.4.76}/src/services/resolve_fallback.py +0 -0
  51. {yourmemory-1.4.73 → yourmemory-1.4.76}/src/services/retrieve.py +0 -0
  52. {yourmemory-1.4.73 → yourmemory-1.4.76}/src/services/session.py +0 -0
  53. {yourmemory-1.4.73 → yourmemory-1.4.76}/src/services/temporal.py +0 -0
  54. {yourmemory-1.4.73 → yourmemory-1.4.76}/src/services/utils.py +0 -0
  55. {yourmemory-1.4.73 → yourmemory-1.4.76}/tests/test_features.py +0 -0
  56. {yourmemory-1.4.73 → yourmemory-1.4.76}/yourmemory.egg-info/dependency_links.txt +0 -0
  57. {yourmemory-1.4.73 → yourmemory-1.4.76}/yourmemory.egg-info/entry_points.txt +0 -0
  58. {yourmemory-1.4.73 → yourmemory-1.4.76}/yourmemory.egg-info/requires.txt +0 -0
  59. {yourmemory-1.4.73 → yourmemory-1.4.76}/yourmemory.egg-info/top_level.txt +0 -0
  60. {yourmemory-1.4.73 → yourmemory-1.4.76}/yourmemory_run.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: yourmemory
3
- Version: 1.4.73
3
+ Version: 1.4.76
4
4
  Summary: Persistent memory for Claude — Ebbinghaus forgetting curve, semantic deduplication, MCP-native
5
5
  Author: Sachit Misra
6
6
  Author-email: mishrasachit1@gmail.com
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "yourmemory"
7
- version = "1.4.73"
7
+ version = "1.4.76"
8
8
  description = "Persistent memory for Claude — Ebbinghaus forgetting curve, semantic deduplication, MCP-native"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -8,7 +8,12 @@ from apscheduler.schedulers.asyncio import AsyncIOScheduler
8
8
  from fastapi import FastAPI
9
9
  from fastapi.responses import StreamingResponse
10
10
  from pydantic import BaseModel
11
- from src.routes import memories, retrieve, agents, ui, graph_viz, proxy, audit
11
+ import time
12
+ from collections import defaultdict, deque
13
+ from threading import Lock
14
+ from fastapi import Request
15
+ from fastapi.responses import JSONResponse
16
+ from src.routes import memories, retrieve, agents, ui, graph_viz, proxy, audit, dsar, compact
12
17
  from src.jobs.decay_job import run as run_decay
13
18
  from src.services.audit import prune_expired as prune_audit
14
19
  from src.db.migrate import migrate
@@ -23,6 +28,8 @@ def _daily_jobs():
23
28
  prune_audit() # retention: drop audit rows older than the (>=90-day) window
24
29
  except Exception:
25
30
  pass
31
+ # Note: memory compaction is event-driven (triggered on store when a cluster reaches
32
+ # N), not a daily sweep — see /auto-store and src/services/compaction.py.
26
33
 
27
34
 
28
35
  @asynccontextmanager
@@ -43,6 +50,43 @@ app.include_router(ui.router)
43
50
  app.include_router(graph_viz.router)
44
51
  app.include_router(proxy.router)
45
52
  app.include_router(audit.router)
53
+ app.include_router(dsar.router)
54
+ app.include_router(compact.router)
55
+
56
+
57
+ # ── Rate limiting (abuse prevention) ────────────────────────────────────────────
58
+ # Per-client sliding-window limiter. Loopback (127.0.0.1) is exempt by default — the
59
+ # local trust boundary is the OS user, and the recall/store hooks burst legitimately;
60
+ # the limit targets network-exposed (hosted) clients. Configure with:
61
+ # YOURMEMORY_RATE_LIMIT max requests per window per client (default 300; 0 disables)
62
+ # YOURMEMORY_RATE_WINDOW window length in seconds (default 60)
63
+ # YOURMEMORY_RATE_LIMIT_LOOPBACK=1 also limit loopback clients
64
+ _RATE_LIMIT = int(os.getenv("YOURMEMORY_RATE_LIMIT", "300"))
65
+ _RATE_WINDOW = int(os.getenv("YOURMEMORY_RATE_WINDOW", "60"))
66
+ _RATE_LOOPBACK = os.getenv("YOURMEMORY_RATE_LIMIT_LOOPBACK", "0") == "1"
67
+ _rate_hits: dict = defaultdict(deque)
68
+ _rate_lock = Lock()
69
+ _LOOPBACK = {"127.0.0.1", "::1", "localhost"}
70
+
71
+
72
+ @app.middleware("http")
73
+ async def _rate_limit(request: Request, call_next):
74
+ if _RATE_LIMIT > 0 and request.url.path != "/health":
75
+ client = request.client.host if request.client else "unknown"
76
+ if _RATE_LOOPBACK or client not in _LOOPBACK:
77
+ now = time.time()
78
+ with _rate_lock:
79
+ dq = _rate_hits[client]
80
+ cutoff = now - _RATE_WINDOW
81
+ while dq and dq[0] < cutoff:
82
+ dq.popleft()
83
+ if len(dq) >= _RATE_LIMIT:
84
+ retry = max(1, int(dq[0] + _RATE_WINDOW - now))
85
+ return JSONResponse(
86
+ {"error": "rate limit exceeded", "retry_after": retry},
87
+ status_code=429, headers={"Retry-After": str(retry)})
88
+ dq.append(now)
89
+ return await call_next(request)
46
90
 
47
91
 
48
92
  @app.get("/health")
@@ -616,4 +660,18 @@ def auto_store_endpoint(req: AutoStoreRequest):
616
660
  except Exception:
617
661
  pass
618
662
 
663
+ # Event-driven compaction: if a just-stored fact's cluster now has >= N closely
664
+ # related memories, compress that cluster immediately so the store stays lean —
665
+ # no daily sweep needed. On by default; disable with YOURMEMORY_COMPACTION=0.
666
+ if os.getenv("YOURMEMORY_COMPACTION", "1") == "1" and to_index:
667
+ try:
668
+ from src.services.compaction import maybe_compact_around
669
+ seen = set()
670
+ for _mid, content, _imp, _cat, _emb in to_index:
671
+ if content and content not in seen:
672
+ seen.add(content)
673
+ maybe_compact_around(user_id, content)
674
+ except Exception:
675
+ pass
676
+
619
677
  return {"stored": len(stored), "facts": stored}
@@ -160,6 +160,66 @@ def _create_audit_table(conn, backend: str) -> None:
160
160
  """)
161
161
 
162
162
 
163
+ def _create_archive_table(conn, backend: str) -> None:
164
+ """Holds originals that were compressed into a summary memory. Lets the live
165
+ `memories` table stay lean (so recall stays fast and clean) while keeping the
166
+ pre-compression facts for reversibility and audit. Idempotent across backends."""
167
+ if backend == "postgres":
168
+ cur = conn.cursor()
169
+ cur.execute("""
170
+ CREATE TABLE IF NOT EXISTS memory_archive (
171
+ orig_id BIGINT,
172
+ user_id TEXT NOT NULL,
173
+ content TEXT NOT NULL,
174
+ category TEXT,
175
+ importance DOUBLE PRECISION,
176
+ agent_id TEXT,
177
+ visibility TEXT,
178
+ created_at TIMESTAMPTZ,
179
+ archived_at TIMESTAMPTZ DEFAULT NOW(),
180
+ summary_id BIGINT
181
+ );
182
+ CREATE INDEX IF NOT EXISTS idx_archive_user ON memory_archive(user_id);
183
+ """)
184
+ conn.commit()
185
+ cur.close()
186
+ elif backend == "duckdb":
187
+ try:
188
+ conn.execute("""
189
+ CREATE TABLE IF NOT EXISTS memory_archive (
190
+ orig_id BIGINT,
191
+ user_id VARCHAR NOT NULL,
192
+ content VARCHAR NOT NULL,
193
+ category VARCHAR,
194
+ importance DOUBLE,
195
+ agent_id VARCHAR,
196
+ visibility VARCHAR,
197
+ created_at TIMESTAMP,
198
+ archived_at TIMESTAMP DEFAULT now(),
199
+ summary_id BIGINT
200
+ )
201
+ """)
202
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_archive_user ON memory_archive(user_id)")
203
+ except Exception as exc:
204
+ print(f"archive table (duckdb) skipped: {exc}", file=sys.stderr)
205
+ else: # sqlite
206
+ conn.executescript("""
207
+ CREATE TABLE IF NOT EXISTS memory_archive (
208
+ orig_id INTEGER,
209
+ user_id TEXT NOT NULL,
210
+ content TEXT NOT NULL,
211
+ category TEXT,
212
+ importance REAL,
213
+ agent_id TEXT,
214
+ visibility TEXT,
215
+ created_at TIMESTAMP,
216
+ archived_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
217
+ summary_id INTEGER
218
+ );
219
+ CREATE INDEX IF NOT EXISTS idx_archive_user ON memory_archive(user_id);
220
+ """)
221
+
222
+
163
223
  def migrate():
164
224
  backend = get_backend()
165
225
 
@@ -199,6 +259,9 @@ def migrate():
199
259
  # ── Append-only, hash-chained audit log (read/write/delete, 90-day+ retention) ──
200
260
  _create_audit_table(conn, backend)
201
261
 
262
+ # ── Archive of originals compressed into summaries (memory compaction) ──
263
+ _create_archive_table(conn, backend)
264
+
202
265
  # ── Post-schema FTS setup ─────────────────────────────────────────────
203
266
  if backend == "sqlite":
204
267
  # Backfill any rows that existed before the FTS table was created.
@@ -0,0 +1,53 @@
1
+ """
2
+ Memory compaction endpoints.
3
+
4
+ POST /compact?userId=… → run compaction now for a user (returns stats)
5
+ GET /users/{user_id}/archive → view memories that were compressed into summaries
6
+
7
+ Compaction also runs daily when YOURMEMORY_COMPACTION=1. See src/services/compaction.py.
8
+ """
9
+
10
+ from fastapi import APIRouter, Query
11
+ from typing import Optional
12
+
13
+ from src.services.compaction import compact_user
14
+ from src.db.connection import get_backend, get_conn, duckdb_rows
15
+
16
+ router = APIRouter()
17
+
18
+
19
+ @router.post("/compact")
20
+ def run_compaction(
21
+ userId: str = Query(..., description="User whose memories to compact"),
22
+ minCluster: Optional[int] = Query(None, ge=2, le=100),
23
+ simThreshold: Optional[float] = Query(None, ge=0.3, le=0.99),
24
+ ):
25
+ return compact_user(userId, min_cluster=minCluster, sim_threshold=simThreshold)
26
+
27
+
28
+ @router.get("/users/{user_id}/archive")
29
+ def view_archive(user_id: str, limit: int = Query(100, ge=1, le=1000)):
30
+ """Originals that were compressed into summaries (newest first)."""
31
+ user_id = user_id.strip().lower()
32
+ backend = get_backend()
33
+ conn = get_conn()
34
+ sql_pg = ("SELECT orig_id, content, category, summary_id, archived_at FROM memory_archive "
35
+ "WHERE user_id = %s ORDER BY archived_at DESC LIMIT %s")
36
+ sql_other = ("SELECT orig_id, content, category, summary_id, archived_at FROM memory_archive "
37
+ "WHERE user_id = ? ORDER BY archived_at DESC LIMIT ?")
38
+ cols = ["orig_id", "content", "category", "summary_id", "archived_at"]
39
+ try:
40
+ if backend == "postgres":
41
+ cur = conn.cursor(); cur.execute(sql_pg, (user_id, limit))
42
+ rows = [dict(zip(cols, r)) for r in cur.fetchall()]; cur.close()
43
+ elif backend == "duckdb":
44
+ rows = duckdb_rows(conn.execute(sql_other, [user_id, limit]))
45
+ else:
46
+ cur = conn.cursor(); cur.execute(sql_other, (user_id, limit))
47
+ rows = [dict(zip(cols, r)) for r in cur.fetchall()]; cur.close()
48
+ finally:
49
+ conn.close()
50
+ for r in rows:
51
+ if r.get("archived_at") is not None:
52
+ r["archived_at"] = str(r["archived_at"])
53
+ return {"count": len(rows), "archived": rows}
@@ -0,0 +1,197 @@
1
+ """
2
+ DSAR / data-portability endpoints — export, right-to-forget purge, and bulk import.
3
+
4
+ GET /users/{user_id}/export → full JSON of a user's memories (DSAR export / backup)
5
+ DELETE /users/{user_id}/memories → purge ALL of a user's data (right-to-forget)
6
+ POST /users/{user_id}/import → bulk restore/seed memories from an export
7
+
8
+ Every operation is recorded in the audit trail. In the default local deployment the
9
+ path `user_id` identifies the data subject (trust boundary is the OS user); a hosted
10
+ deployment must additionally authenticate the caller before these are exposed.
11
+
12
+ See docs/policies/06-data-retention-deletion-policy.md.
13
+ """
14
+
15
+ from fastapi import APIRouter, HTTPException
16
+ from pydantic import BaseModel
17
+ from typing import Optional, List
18
+
19
+ from src.services.embed import embed
20
+ from src.services.extract import categorize
21
+ from src.db.connection import get_backend, get_conn, emb_to_db, duckdb_rows
22
+ from src.services.audit import log_event
23
+
24
+ router = APIRouter()
25
+
26
+ _EXPORT_COLS = ("id, content, category, importance, recall_count, "
27
+ "last_accessed_at, created_at, agent_id, visibility, context_paths")
28
+ _EXPORT_KEYS = ["id", "content", "category", "importance", "recall_count",
29
+ "last_accessed_at", "created_at", "agent_id", "visibility", "context_paths"]
30
+
31
+
32
+ def _rows_for_user(conn, backend: str, user_id: str) -> list[dict]:
33
+ sql_pg = f"SELECT {_EXPORT_COLS} FROM memories WHERE user_id = %s ORDER BY id"
34
+ sql_other = f"SELECT {_EXPORT_COLS} FROM memories WHERE user_id = ? ORDER BY id"
35
+ if backend == "postgres":
36
+ from psycopg2.extras import RealDictCursor
37
+ cur = conn.cursor(cursor_factory=RealDictCursor)
38
+ cur.execute(sql_pg, (user_id,))
39
+ rows = [dict(r) for r in cur.fetchall()]
40
+ cur.close()
41
+ return rows
42
+ if backend == "duckdb":
43
+ return duckdb_rows(conn.execute(sql_other, [user_id]))
44
+ cur = conn.cursor()
45
+ cur.execute(sql_other, (user_id,))
46
+ cols = [d[0] for d in cur.description]
47
+ rows = [dict(zip(cols, r)) for r in cur.fetchall()]
48
+ cur.close()
49
+ return rows
50
+
51
+
52
+ # ── GET /users/{user_id}/export ────────────────────────────────────────────────
53
+
54
+ @router.get("/users/{user_id}/export")
55
+ def export_user(user_id: str):
56
+ """Return all of a user's memories as JSON (DSAR access request / backup)."""
57
+ user_id = user_id.strip().lower()
58
+ backend = get_backend()
59
+ conn = get_conn()
60
+ try:
61
+ rows = _rows_for_user(conn, backend, user_id)
62
+ finally:
63
+ conn.close()
64
+
65
+ # Stringify timestamps for clean JSON.
66
+ for r in rows:
67
+ for k in ("last_accessed_at", "created_at"):
68
+ if r.get(k) is not None:
69
+ r[k] = str(r[k])
70
+
71
+ from datetime import datetime, timezone
72
+ log_event("read", "export", user_id, detail={"count": len(rows)})
73
+ return {
74
+ "user_id": user_id,
75
+ "exported_at": datetime.now(timezone.utc).isoformat(),
76
+ "count": len(rows),
77
+ "memories": rows,
78
+ }
79
+
80
+
81
+ # ── DELETE /users/{user_id}/memories (right-to-forget) ─────────────────────────
82
+
83
+ @router.delete("/users/{user_id}/memories")
84
+ def purge_user(user_id: str):
85
+ """Delete ALL of a user's memories, graph nodes, and conversation buffer."""
86
+ user_id = user_id.strip().lower()
87
+ backend = get_backend()
88
+ conn = get_conn()
89
+ deleted = 0
90
+ try:
91
+ # Count first (for the audit detail + response).
92
+ if backend == "postgres":
93
+ cur = conn.cursor()
94
+ cur.execute("SELECT COUNT(*) FROM memories WHERE user_id = %s", (user_id,))
95
+ deleted = cur.fetchone()[0]
96
+ cur.execute("DELETE FROM memories WHERE user_id = %s", (user_id,))
97
+ cur.execute("DELETE FROM conversation_buffer WHERE user_id = %s", (user_id,))
98
+ conn.commit()
99
+ cur.close()
100
+ elif backend == "duckdb":
101
+ deleted = conn.execute("SELECT COUNT(*) FROM memories WHERE user_id = ?", [user_id]).fetchone()[0]
102
+ conn.execute("DELETE FROM memories WHERE user_id = ?", [user_id])
103
+ try:
104
+ conn.execute("DELETE FROM conversation_buffer WHERE user_id = ?", [user_id])
105
+ except Exception:
106
+ pass
107
+ else: # sqlite
108
+ cur = conn.cursor()
109
+ cur.execute("SELECT COUNT(*) FROM memories WHERE user_id = ?", (user_id,))
110
+ deleted = cur.fetchone()[0]
111
+ cur.execute("DELETE FROM memories WHERE user_id = ?", (user_id,))
112
+ try:
113
+ cur.execute("DELETE FROM conversation_buffer WHERE user_id = ?", (user_id,))
114
+ except Exception:
115
+ pass
116
+ conn.commit()
117
+ cur.close()
118
+ finally:
119
+ conn.close()
120
+
121
+ # Best-effort: drop the user's graph nodes so recall can't resurface them.
122
+ try:
123
+ from src.graph import get_graph_backend
124
+ gb = get_graph_backend()
125
+ for node in gb.get_all_nodes_for_user(user_id):
126
+ try:
127
+ gb.delete_node(node["memory_id"])
128
+ except Exception:
129
+ pass
130
+ except Exception:
131
+ pass
132
+
133
+ # The audit entry is retained (immutability / accountability) — it holds only the
134
+ # user id + count, never memory content, so it is not personal content.
135
+ log_event("delete", "purge", user_id, detail={"count": deleted})
136
+ return {"purged": True, "user_id": user_id, "deleted": deleted}
137
+
138
+
139
+ # ── POST /users/{user_id}/import ────────────────────────────────────────────────
140
+
141
+ class ImportRequest(BaseModel):
142
+ memories: List[dict]
143
+ overwrite: bool = False # reserved; ON CONFLICT already upserts by (user_id, content)
144
+
145
+
146
+ @router.post("/users/{user_id}/import")
147
+ def import_user(user_id: str, req: ImportRequest):
148
+ """Bulk restore/seed memories from an export. Re-embeds each item; idempotent on
149
+ (user_id, content). Bypasses the relevance judge — this is restoring vetted data."""
150
+ user_id = user_id.strip().lower()
151
+ items = req.memories or []
152
+ if not items:
153
+ return {"imported": 0, "skipped": 0}
154
+
155
+ backend = get_backend()
156
+ conn = get_conn()
157
+ cur = conn.cursor() if backend != "duckdb" else None
158
+ imported, skipped = 0, 0
159
+ try:
160
+ for it in items:
161
+ content = str(it.get("content", "")).strip()
162
+ if len(content) < 2:
163
+ skipped += 1
164
+ continue
165
+ importance = float(it.get("importance", 0.5) or 0.5)
166
+ importance = max(0.0, min(1.0, importance))
167
+ category = str(it.get("category", "") or "").strip().lower() or categorize(content)
168
+ try:
169
+ emb_str = emb_to_db(embed(content), backend)
170
+ if backend == "postgres":
171
+ cur.execute(
172
+ "INSERT INTO memories (user_id, content, embedding, importance, category) "
173
+ "VALUES (%s, %s, %s::vector, %s, %s) "
174
+ "ON CONFLICT (user_id, content) DO UPDATE SET importance = EXCLUDED.importance",
175
+ (user_id, content, emb_str, importance, category))
176
+ elif backend == "duckdb":
177
+ conn.execute(
178
+ "INSERT INTO memories (user_id, content, embedding, importance, category) "
179
+ "VALUES (?, ?, ?, ?, ?) ON CONFLICT (user_id, content) DO UPDATE SET importance = excluded.importance",
180
+ [user_id, content, emb_str, importance, category])
181
+ else:
182
+ cur.execute(
183
+ "INSERT INTO memories (user_id, content, embedding, importance, category) "
184
+ "VALUES (?, ?, ?, ?, ?) ON CONFLICT (user_id, content) DO UPDATE SET importance = excluded.importance",
185
+ (user_id, content, emb_str, importance, category))
186
+ imported += 1
187
+ except Exception:
188
+ skipped += 1
189
+ if backend != "duckdb":
190
+ conn.commit()
191
+ finally:
192
+ if cur:
193
+ cur.close()
194
+ conn.close()
195
+
196
+ log_event("write", "import", user_id, detail={"imported": imported, "skipped": skipped})
197
+ return {"imported": imported, "skipped": skipped}
@@ -0,0 +1,292 @@
1
+ """
2
+ Memory compaction — auto-compress clusters of related memories into one structured
3
+ summary and archive the originals.
4
+
5
+ Why: a memory store that only ever grows bloats and its signal-to-noise degrades. After
6
+ several memories accumulate about the same topic/entity, we summarize them into a single
7
+ consolidated memory and move the originals to `memory_archive`. The live `memories` table
8
+ stays lean (recall stays fast and clean), the summary preserves every distinct detail,
9
+ and the originals remain recoverable and auditable.
10
+
11
+ Flow per user:
12
+ 1. Embed all live memories, greedily cluster by cosine similarity.
13
+ 2. For each cluster of >= MIN_CLUSTER members, LLM-summarize into one memory.
14
+ 3. Insert the summary, copy originals to memory_archive, delete originals from memories.
15
+ 4. Re-index the graph (drop original nodes, index the summary). Audit the compaction.
16
+
17
+ Conservative by design: the summary prompt is instructed to preserve all facts, the
18
+ threshold groups clearly-related memories, and originals are archived (never lost).
19
+ """
20
+
21
+ import json
22
+ import math
23
+ import os
24
+ import urllib.request
25
+ from datetime import datetime, timezone
26
+
27
+ from src.services.embed import embed
28
+ from src.services.extract import categorize
29
+ from src.db.connection import get_backend, get_conn, emb_to_db, duckdb_rows
30
+ from src.services.audit import log_event
31
+
32
+ MIN_CLUSTER = int(os.getenv("YOURMEMORY_COMPACT_MIN", "5")) # min memories to compress
33
+ SIM_THRESHOLD = float(os.getenv("YOURMEMORY_COMPACT_SIM", "0.62")) # cosine to group as "related"
34
+ MAX_SCAN = int(os.getenv("YOURMEMORY_COMPACT_MAX", "2000")) # cap per run (O(n^2) guard)
35
+
36
+
37
+ def _cosine(a, b) -> float:
38
+ dot = sum(x * y for x, y in zip(a, b))
39
+ na = math.sqrt(sum(x * x for x in a))
40
+ nb = math.sqrt(sum(x * x for x in b))
41
+ return dot / (na * nb) if na and nb else 0.0
42
+
43
+
44
+ def _summarize(contents: list[str]) -> str | None:
45
+ """LLM-compress related facts into ONE structured memory, preserving every detail."""
46
+ joined = "\n".join(f"- {c}" for c in contents)
47
+ prompt = (
48
+ "You are compressing several related memory facts about the same topic into ONE "
49
+ "consolidated memory. Preserve EVERY distinct detail — names, numbers, dates, "
50
+ "preferences, decisions. Do not drop or invent information. Merge overlaps, keep "
51
+ "specifics. Output a single self-contained declarative summary (1–3 sentences), "
52
+ "no preamble, no markdown.\n\n"
53
+ f"Facts:\n{joined}\n\nConsolidated memory:"
54
+ )
55
+ backend = os.getenv("YOURMEMORY_EXTRACT_BACKEND", "ollama").lower()
56
+ try:
57
+ if backend == "anthropic":
58
+ from src.services.extract import _anthropic_complete # type: ignore
59
+ return _anthropic_complete(prompt).strip() or None # best-effort if present
60
+ except Exception:
61
+ pass
62
+ # Default: Ollama
63
+ url = os.getenv("YOURMEMORY_OLLAMA_URL", "http://localhost:11434")
64
+ model = os.getenv("YOURMEMORY_OLLAMA_MODEL", "qwen2.5:7b")
65
+ payload = json.dumps({
66
+ "model": model, "prompt": prompt, "stream": False,
67
+ "keep_alive": os.getenv("YOURMEMORY_OLLAMA_KEEPALIVE", "30m"),
68
+ "options": {"temperature": 0, "num_predict": 220},
69
+ }).encode()
70
+ try:
71
+ req = urllib.request.Request(f"{url}/api/generate", data=payload,
72
+ headers={"Content-Type": "application/json"})
73
+ with urllib.request.urlopen(req, timeout=60) as r:
74
+ out = json.loads(r.read()).get("response", "").strip()
75
+ return out or None
76
+ except Exception:
77
+ return None
78
+
79
+
80
+ def _live_memories(conn, backend: str, user_id: str) -> list[dict]:
81
+ cols = "id, content, category, importance, agent_id, visibility, created_at"
82
+ if backend == "postgres":
83
+ from psycopg2.extras import RealDictCursor
84
+ cur = conn.cursor(cursor_factory=RealDictCursor)
85
+ cur.execute(f"SELECT {cols} FROM memories WHERE user_id = %s ORDER BY id LIMIT %s",
86
+ (user_id, MAX_SCAN))
87
+ rows = [dict(r) for r in cur.fetchall()]; cur.close(); return rows
88
+ if backend == "duckdb":
89
+ return duckdb_rows(conn.execute(
90
+ f"SELECT {cols} FROM memories WHERE user_id = ? ORDER BY id LIMIT ?", [user_id, MAX_SCAN]))
91
+ cur = conn.cursor()
92
+ cur.execute(f"SELECT {cols} FROM memories WHERE user_id = ? ORDER BY id LIMIT ?", (user_id, MAX_SCAN))
93
+ cn = [d[0] for d in cur.description]
94
+ rows = [dict(zip(cn, r)) for r in cur.fetchall()]; cur.close(); return rows
95
+
96
+
97
+ def compact_user(user_id: str, min_cluster: int = None, sim_threshold: float = None) -> dict:
98
+ """Compress related-memory clusters for one user. Returns stats."""
99
+ user_id = (user_id or "").strip().lower()
100
+ min_cluster = min_cluster or MIN_CLUSTER
101
+ sim_threshold = sim_threshold if sim_threshold is not None else SIM_THRESHOLD
102
+ backend = get_backend()
103
+
104
+ conn = get_conn()
105
+ try:
106
+ mems = _live_memories(conn, backend, user_id)
107
+ finally:
108
+ conn.close()
109
+ if len(mems) < min_cluster:
110
+ return {"clusters": 0, "archived": 0, "summaries": 0, "scanned": len(mems)}
111
+
112
+ # Embed + greedy cluster by cosine similarity.
113
+ vecs = [embed(m["content"]) for m in mems]
114
+ used, clusters = set(), []
115
+ for i in range(len(mems)):
116
+ if i in used:
117
+ continue
118
+ group = [i]; used.add(i)
119
+ for j in range(i + 1, len(mems)):
120
+ if j in used:
121
+ continue
122
+ if _cosine(vecs[i], vecs[j]) >= sim_threshold:
123
+ group.append(j); used.add(j)
124
+ if len(group) >= min_cluster:
125
+ clusters.append(group)
126
+
127
+ if not clusters:
128
+ return {"clusters": 0, "archived": 0, "summaries": 0, "scanned": len(mems)}
129
+
130
+ archived_total, summaries = 0, 0
131
+ for group in clusters:
132
+ members = [mems[k] for k in group]
133
+ summary = _summarize([m["content"] for m in members])
134
+ if not summary or len(summary) < 12:
135
+ continue # summarization failed → leave the cluster untouched
136
+ # Inherit the strongest signal from the cluster.
137
+ importance = max(float(m["importance"] or 0.5) for m in members)
138
+ category = categorize(summary)
139
+ agent_id = members[0].get("agent_id")
140
+ visibility = members[0].get("visibility") or "shared"
141
+ summary_id = _apply_compaction(backend, user_id, summary, importance, category,
142
+ agent_id, visibility, members)
143
+ if summary_id is not None:
144
+ archived_total += len(members)
145
+ summaries += 1
146
+
147
+ if summaries:
148
+ log_event("write", "compact", user_id,
149
+ detail={"clusters": summaries, "archived": archived_total, "scanned": len(mems)})
150
+ return {"clusters": len(clusters), "archived": archived_total,
151
+ "summaries": summaries, "scanned": len(mems)}
152
+
153
+
154
+ def _fetch_rows_by_ids(user_id: str, ids: list) -> list[dict]:
155
+ if not ids:
156
+ return []
157
+ backend = get_backend()
158
+ conn = get_conn()
159
+ cols = "id, content, category, importance, agent_id, visibility, created_at"
160
+ ph = ",".join(["%s" if backend == "postgres" else "?"] * len(ids))
161
+ sql = f"SELECT {cols} FROM memories WHERE user_id = {'%s' if backend=='postgres' else '?'} AND id IN ({ph})"
162
+ params = [user_id, *ids]
163
+ try:
164
+ if backend == "duckdb":
165
+ return duckdb_rows(conn.execute(sql, params))
166
+ cur = conn.cursor()
167
+ cur.execute(sql, tuple(params) if backend == "postgres" else params)
168
+ cn = [d[0] for d in cur.description]
169
+ rows = [dict(zip(cn, r)) for r in cur.fetchall()]; cur.close(); return rows
170
+ finally:
171
+ conn.close()
172
+
173
+
174
+ def maybe_compact_around(user_id: str, seed_content: str,
175
+ min_cluster: int = None, sim_threshold: float = None) -> int | None:
176
+ """Event-driven compaction: after a memory is stored, check whether its neighborhood
177
+ now has >= N closely-related memories; if so, compress just that cluster immediately.
178
+ Targeted (one similarity lookup, not an O(n^2) full scan). Returns the summary id."""
179
+ user_id = (user_id or "").strip().lower()
180
+ min_cluster = min_cluster or MIN_CLUSTER
181
+ sim = sim_threshold if sim_threshold is not None else SIM_THRESHOLD
182
+ if not seed_content or len(seed_content) < 4:
183
+ return None
184
+ try:
185
+ from src.services.retrieve import retrieve
186
+ res = retrieve(user_id, seed_content, top_k=max(min_cluster * 4, 20), no_graph=True)
187
+ except Exception:
188
+ return None
189
+ members = [m for m in res.get("memories", []) if m.get("similarity", 0) >= sim]
190
+ if len(members) < min_cluster:
191
+ return None # not enough of the same thing yet — leave it
192
+ rows = _fetch_rows_by_ids(user_id, [m["id"] for m in members])
193
+ if len(rows) < min_cluster:
194
+ return None
195
+ summary = _summarize([r["content"] for r in rows])
196
+ if not summary or len(summary) < 12:
197
+ return None
198
+ importance = max(float(r["importance"] or 0.5) for r in rows)
199
+ category = categorize(summary)
200
+ agent_id = rows[0].get("agent_id")
201
+ visibility = rows[0].get("visibility") or "shared"
202
+ summary_id = _apply_compaction(get_backend(), user_id, summary, importance, category,
203
+ agent_id, visibility, rows)
204
+ if summary_id is not None:
205
+ log_event("write", "compact", user_id,
206
+ detail={"clusters": 1, "archived": len(rows), "trigger": "count"})
207
+ return summary_id
208
+
209
+
210
+ def _apply_compaction(backend, user_id, summary, importance, category, agent_id,
211
+ visibility, members) -> int | None:
212
+ """Insert the summary memory, archive the originals, delete them. Returns summary id."""
213
+ emb_str = emb_to_db(embed(summary), backend)
214
+ ids = [m["id"] for m in members]
215
+ conn = get_conn()
216
+ cur = conn.cursor() if backend != "duckdb" else None
217
+ summary_id = None
218
+ try:
219
+ # 1. Insert the consolidated summary.
220
+ if backend == "postgres":
221
+ cur.execute(
222
+ "INSERT INTO memories (user_id, content, embedding, importance, category, agent_id, visibility) "
223
+ "VALUES (%s,%s,%s::vector,%s,%s,%s,%s) "
224
+ "ON CONFLICT (user_id, content) DO UPDATE SET importance = EXCLUDED.importance RETURNING id",
225
+ (user_id, summary, emb_str, importance, category, agent_id, visibility))
226
+ summary_id = cur.fetchone()[0]
227
+ elif backend == "duckdb":
228
+ conn.execute(
229
+ "INSERT INTO memories (user_id, content, embedding, importance, category, agent_id, visibility) "
230
+ "VALUES (?,?,?,?,?,?,?) ON CONFLICT (user_id, content) DO UPDATE SET importance = excluded.importance",
231
+ [user_id, summary, emb_str, importance, category, agent_id, visibility])
232
+ summary_id = conn.execute("SELECT id FROM memories WHERE user_id=? AND content=?",
233
+ [user_id, summary]).fetchone()[0]
234
+ else:
235
+ cur.execute(
236
+ "INSERT INTO memories (user_id, content, embedding, importance, category, agent_id, visibility) "
237
+ "VALUES (?,?,?,?,?,?,?) ON CONFLICT (user_id, content) DO UPDATE SET importance = excluded.importance",
238
+ (user_id, summary, emb_str, importance, category, agent_id, visibility))
239
+ cur.execute("SELECT id FROM memories WHERE user_id=? AND content=?", (user_id, summary))
240
+ summary_id = cur.fetchone()[0]
241
+
242
+ # 2. Archive originals + 3. delete them (skip the summary row if it collided with one).
243
+ for m in members:
244
+ if m["id"] == summary_id:
245
+ continue
246
+ vals = (m["id"], user_id, m["content"], m.get("category"), m.get("importance"),
247
+ m.get("agent_id"), m.get("visibility"), m.get("created_at"), summary_id)
248
+ if backend == "postgres":
249
+ cur.execute(
250
+ "INSERT INTO memory_archive (orig_id,user_id,content,category,importance,agent_id,visibility,created_at,summary_id) "
251
+ "VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s)", vals)
252
+ cur.execute("DELETE FROM memories WHERE id = %s", (m["id"],))
253
+ elif backend == "duckdb":
254
+ conn.execute(
255
+ "INSERT INTO memory_archive (orig_id,user_id,content,category,importance,agent_id,visibility,created_at,summary_id) "
256
+ "VALUES (?,?,?,?,?,?,?,?,?)", list(vals))
257
+ conn.execute("DELETE FROM memories WHERE id = ?", [m["id"]])
258
+ else:
259
+ cur.execute(
260
+ "INSERT INTO memory_archive (orig_id,user_id,content,category,importance,agent_id,visibility,created_at,summary_id) "
261
+ "VALUES (?,?,?,?,?,?,?,?,?)", vals)
262
+ cur.execute("DELETE FROM memories WHERE id = ?", (m["id"],))
263
+ if backend != "duckdb":
264
+ conn.commit()
265
+ except Exception:
266
+ if backend == "postgres":
267
+ try: conn.rollback()
268
+ except Exception: pass
269
+ summary_id = None
270
+ finally:
271
+ if cur: cur.close()
272
+ conn.close()
273
+
274
+ # Best-effort graph upkeep: drop original nodes, index the summary.
275
+ if summary_id is not None:
276
+ try:
277
+ from src.graph import get_graph_backend
278
+ gb = get_graph_backend()
279
+ for m in members:
280
+ if m["id"] != summary_id:
281
+ try: gb.delete_node(m["id"])
282
+ except Exception: pass
283
+ except Exception:
284
+ pass
285
+ try:
286
+ from src.graph.graph_store import index_memory
287
+ index_memory(memory_id=summary_id, user_id=user_id, content=summary,
288
+ strength=importance, importance=importance, category=category,
289
+ embedding=list(embed(summary)))
290
+ except Exception:
291
+ pass
292
+ return summary_id
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: yourmemory
3
- Version: 1.4.73
3
+ Version: 1.4.76
4
4
  Summary: Persistent memory for Claude — Ebbinghaus forgetting curve, semantic deduplication, MCP-native
5
5
  Author: Sachit Misra
6
6
  Author-email: mishrasachit1@gmail.com
@@ -27,6 +27,8 @@ src/jobs/decay_job.py
27
27
  src/routes/__init__.py
28
28
  src/routes/agents.py
29
29
  src/routes/audit.py
30
+ src/routes/compact.py
31
+ src/routes/dsar.py
30
32
  src/routes/graph_viz.py
31
33
  src/routes/memories.py
32
34
  src/routes/proxy.py
@@ -36,6 +38,7 @@ src/services/__init__.py
36
38
  src/services/agent_registry.py
37
39
  src/services/api_keys.py
38
40
  src/services/audit.py
41
+ src/services/compaction.py
39
42
  src/services/decay.py
40
43
  src/services/embed.py
41
44
  src/services/extract.py
File without changes
File without changes
File without changes
File without changes
File without changes