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.
- {yourmemory-1.4.73 → yourmemory-1.4.76}/PKG-INFO +1 -1
- {yourmemory-1.4.73 → yourmemory-1.4.76}/pyproject.toml +1 -1
- {yourmemory-1.4.73 → yourmemory-1.4.76}/src/app.py +59 -1
- {yourmemory-1.4.73 → yourmemory-1.4.76}/src/db/migrate.py +63 -0
- yourmemory-1.4.76/src/routes/compact.py +53 -0
- yourmemory-1.4.76/src/routes/dsar.py +197 -0
- yourmemory-1.4.76/src/services/compaction.py +292 -0
- {yourmemory-1.4.73 → yourmemory-1.4.76}/yourmemory.egg-info/PKG-INFO +1 -1
- {yourmemory-1.4.73 → yourmemory-1.4.76}/yourmemory.egg-info/SOURCES.txt +3 -0
- {yourmemory-1.4.73 → yourmemory-1.4.76}/LICENSE +0 -0
- {yourmemory-1.4.73 → yourmemory-1.4.76}/README.md +0 -0
- {yourmemory-1.4.73 → yourmemory-1.4.76}/memory_mcp.py +0 -0
- {yourmemory-1.4.73 → yourmemory-1.4.76}/setup.cfg +0 -0
- {yourmemory-1.4.73 → yourmemory-1.4.76}/src/__init__.py +0 -0
- {yourmemory-1.4.73 → yourmemory-1.4.76}/src/db/connection.py +0 -0
- {yourmemory-1.4.73 → yourmemory-1.4.76}/src/db/duckdb_schema.sql +0 -0
- {yourmemory-1.4.73 → yourmemory-1.4.76}/src/db/schema.sql +0 -0
- {yourmemory-1.4.73 → yourmemory-1.4.76}/src/db/sqlite_schema.sql +0 -0
- {yourmemory-1.4.73 → yourmemory-1.4.76}/src/graph/__init__.py +0 -0
- {yourmemory-1.4.73 → yourmemory-1.4.76}/src/graph/backend.py +0 -0
- {yourmemory-1.4.73 → yourmemory-1.4.76}/src/graph/graph_store.py +0 -0
- {yourmemory-1.4.73 → yourmemory-1.4.76}/src/graph/neo4j_backend.py +0 -0
- {yourmemory-1.4.73 → yourmemory-1.4.76}/src/graph/networkx_backend.py +0 -0
- {yourmemory-1.4.73 → yourmemory-1.4.76}/src/graph/svo_extract.py +0 -0
- {yourmemory-1.4.73 → yourmemory-1.4.76}/src/hook_templates/__init__.py +0 -0
- {yourmemory-1.4.73 → yourmemory-1.4.76}/src/hook_templates/yourmemory_observe.py +0 -0
- {yourmemory-1.4.73 → yourmemory-1.4.76}/src/hook_templates/yourmemory_recall.py +0 -0
- {yourmemory-1.4.73 → yourmemory-1.4.76}/src/hook_templates/yourmemory_recall.sh +0 -0
- {yourmemory-1.4.73 → yourmemory-1.4.76}/src/hook_templates/yourmemory_server.py +0 -0
- {yourmemory-1.4.73 → yourmemory-1.4.76}/src/hook_templates/yourmemory_store.py +0 -0
- {yourmemory-1.4.73 → yourmemory-1.4.76}/src/hook_templates/yourmemory_user.sh +0 -0
- {yourmemory-1.4.73 → yourmemory-1.4.76}/src/jobs/decay_job.py +0 -0
- {yourmemory-1.4.73 → yourmemory-1.4.76}/src/routes/__init__.py +0 -0
- {yourmemory-1.4.73 → yourmemory-1.4.76}/src/routes/agents.py +0 -0
- {yourmemory-1.4.73 → yourmemory-1.4.76}/src/routes/audit.py +0 -0
- {yourmemory-1.4.73 → yourmemory-1.4.76}/src/routes/graph_viz.py +0 -0
- {yourmemory-1.4.73 → yourmemory-1.4.76}/src/routes/memories.py +0 -0
- {yourmemory-1.4.73 → yourmemory-1.4.76}/src/routes/proxy.py +0 -0
- {yourmemory-1.4.73 → yourmemory-1.4.76}/src/routes/retrieve.py +0 -0
- {yourmemory-1.4.73 → yourmemory-1.4.76}/src/routes/ui.py +0 -0
- {yourmemory-1.4.73 → yourmemory-1.4.76}/src/services/__init__.py +0 -0
- {yourmemory-1.4.73 → yourmemory-1.4.76}/src/services/agent_registry.py +0 -0
- {yourmemory-1.4.73 → yourmemory-1.4.76}/src/services/api_keys.py +0 -0
- {yourmemory-1.4.73 → yourmemory-1.4.76}/src/services/audit.py +0 -0
- {yourmemory-1.4.73 → yourmemory-1.4.76}/src/services/decay.py +0 -0
- {yourmemory-1.4.73 → yourmemory-1.4.76}/src/services/embed.py +0 -0
- {yourmemory-1.4.73 → yourmemory-1.4.76}/src/services/extract.py +0 -0
- {yourmemory-1.4.73 → yourmemory-1.4.76}/src/services/extract_fallback.py +0 -0
- {yourmemory-1.4.73 → yourmemory-1.4.76}/src/services/resolve.py +0 -0
- {yourmemory-1.4.73 → yourmemory-1.4.76}/src/services/resolve_fallback.py +0 -0
- {yourmemory-1.4.73 → yourmemory-1.4.76}/src/services/retrieve.py +0 -0
- {yourmemory-1.4.73 → yourmemory-1.4.76}/src/services/session.py +0 -0
- {yourmemory-1.4.73 → yourmemory-1.4.76}/src/services/temporal.py +0 -0
- {yourmemory-1.4.73 → yourmemory-1.4.76}/src/services/utils.py +0 -0
- {yourmemory-1.4.73 → yourmemory-1.4.76}/tests/test_features.py +0 -0
- {yourmemory-1.4.73 → yourmemory-1.4.76}/yourmemory.egg-info/dependency_links.txt +0 -0
- {yourmemory-1.4.73 → yourmemory-1.4.76}/yourmemory.egg-info/entry_points.txt +0 -0
- {yourmemory-1.4.73 → yourmemory-1.4.76}/yourmemory.egg-info/requires.txt +0 -0
- {yourmemory-1.4.73 → yourmemory-1.4.76}/yourmemory.egg-info/top_level.txt +0 -0
- {yourmemory-1.4.73 → yourmemory-1.4.76}/yourmemory_run.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "yourmemory"
|
|
7
|
-
version = "1.4.
|
|
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
|
-
|
|
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
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|