agentmemdb 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
agentmemdb/server.py ADDED
@@ -0,0 +1,618 @@
1
+ """The MCP server: memory tools over one SQLite database shared by every connected agent."""
2
+
3
+ import base64
4
+ import functools
5
+ import json
6
+ import sqlite3
7
+ from collections.abc import Callable
8
+ from datetime import datetime, time, timedelta, timezone
9
+ from importlib.resources import files
10
+ from typing import Any, ParamSpec
11
+
12
+ from mcp.server.fastmcp import FastMCP
13
+ from mcp.types import Icon
14
+
15
+ from agentmemdb import __version__
16
+ from agentmemdb.config import load_settings
17
+ from agentmemdb.models import (
18
+ ToolError,
19
+ check_category,
20
+ check_expiry,
21
+ check_importance,
22
+ clamp,
23
+ is_expired,
24
+ parse_entities,
25
+ parse_tags,
26
+ utc_now,
27
+ )
28
+ from agentmemdb.search import filters, find_similar, group_similar, search
29
+ from agentmemdb.storage import Store
30
+
31
+ PROJECT_URL = "https://github.com/harsh-gulhane/agentmemdb"
32
+
33
+ P = ParamSpec("P")
34
+
35
+
36
+ def _icon() -> Icon:
37
+ svg = files("agentmemdb").joinpath("icon.svg").read_bytes()
38
+ return Icon(src="data:image/svg+xml;base64," + base64.b64encode(svg).decode(), mimeType="image/svg+xml")
39
+
40
+
41
+ settings = load_settings()
42
+ store = Store(settings.db_path)
43
+
44
+ mcp = FastMCP(
45
+ name="agentmemdb",
46
+ instructions=settings.instructions,
47
+ website_url=PROJECT_URL,
48
+ icons=[_icon()],
49
+ log_level="WARNING",
50
+ )
51
+ # FastMCP has no version argument; report ours in serverInfo instead of the mcp SDK's
52
+ mcp._mcp_server.version = __version__
53
+
54
+
55
+ def managed(fn: Callable[P, str]) -> Callable[P, str]:
56
+ """Return ToolError as {"error": ...} and always roll back and close the call's connections."""
57
+
58
+ @functools.wraps(fn)
59
+ def wrapper(*args: P.args, **kwargs: P.kwargs) -> str:
60
+ current = store
61
+ mark = current.open_connections
62
+ try:
63
+ return fn(*args, **kwargs)
64
+ except ToolError as err:
65
+ return _json({"error": str(err)})
66
+ finally:
67
+ current.release(mark)
68
+
69
+ return wrapper
70
+
71
+
72
+ def tool(fn: Callable[P, str]) -> Callable[P, str]:
73
+ """Register fn as an MCP tool."""
74
+ wrapped = managed(fn)
75
+ mcp.tool()(wrapped)
76
+ return wrapped
77
+
78
+
79
+ def _json(obj: Any) -> str:
80
+ return json.dumps(obj, ensure_ascii=False)
81
+
82
+
83
+ def _client_name() -> str:
84
+ """Name the connected MCP client reports (e.g. "claude-code"); empty outside a request."""
85
+ try:
86
+ params = mcp.get_context().session.client_params
87
+ except ValueError:
88
+ return ""
89
+ return params.clientInfo.name if params else ""
90
+
91
+
92
+ def _get_memory_row(db: sqlite3.Connection, memory_id: int, columns: str = "*") -> sqlite3.Row:
93
+ row: sqlite3.Row | None = db.execute(f"SELECT {columns} FROM memories WHERE id = ?", (memory_id,)).fetchone()
94
+ if not row:
95
+ raise ToolError(f"Memory with id {memory_id} not found")
96
+ return row
97
+
98
+
99
+ def _rows(db: sqlite3.Connection, sql: str, params: Any = ()) -> list[dict[str, Any]]:
100
+ return [dict(r) for r in db.execute(sql, params)]
101
+
102
+
103
+ # ─── Core memory tools ───────────────────────────────────────────────
104
+
105
+
106
+ @tool
107
+ def save_memory(
108
+ topic: str,
109
+ content: str,
110
+ category: str = "other",
111
+ tags: str = "",
112
+ importance: str = "normal",
113
+ source: str = "",
114
+ thread_id: str = "",
115
+ entities: str = "",
116
+ expires_at: str = "",
117
+ ) -> str:
118
+ """Save a piece of context/memory for future conversations.
119
+ Warns about likely duplicates (consider append_memory instead) and adds configured auto-tags.
120
+
121
+ Args:
122
+ topic: Short title (e.g. "billing-api Postgres migration decision", "AAPL stock analysis")
123
+ content: Detailed context to remember — include conclusions, data, links, action items
124
+ category: One of: conversation, decision, incident, research, person, project, howto, config, other
125
+ tags: Comma-separated tags (e.g. "postgres,database,billing")
126
+ importance: One of: low, normal, high, critical
127
+ source: Where this came from (default: the connected client's name, e.g. "claude-code")
128
+ thread_id: Link related memories together with the same thread ID (e.g. "incident-2026-03-24")
129
+ entities: JSON array of objects, e.g. [{"type":"service","value":"billing-api"},{"type":"person","value":"alice"}]
130
+ expires_at: Optional expiry date (YYYY-MM-DD) after which this memory is less relevant
131
+ """
132
+ check_category(category)
133
+ check_importance(importance)
134
+ if expires_at:
135
+ check_expiry(expires_at)
136
+ entity_list = parse_entities(entities)
137
+ source = source or _client_name()
138
+
139
+ db = store.connect()
140
+ duplicates = find_similar(db, topic)
141
+ auto_tags = settings.tags_for(topic, content)
142
+ tags_str = ",".join(dict.fromkeys(parse_tags(tags) + auto_tags))
143
+
144
+ now = utc_now().isoformat()
145
+ cursor = db.execute(
146
+ """INSERT INTO memories
147
+ (topic, content, category, tags, importance, source, thread_id, expires_at, created_at, updated_at)
148
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
149
+ (topic, content, category, tags_str, importance, source, thread_id, expires_at, now, now),
150
+ )
151
+ memory_id = cursor.lastrowid
152
+ db.executemany(
153
+ "INSERT INTO entities (memory_id, entity_type, entity_value) VALUES (?, ?, ?)",
154
+ [(memory_id, t, v) for t, v in entity_list],
155
+ )
156
+ db.execute(
157
+ "INSERT INTO memory_history (memory_id, action, content_snippet, created_at) VALUES (?, ?, ?, ?)",
158
+ (memory_id, "created", content[:100], now),
159
+ )
160
+ db.commit()
161
+
162
+ result: dict[str, Any] = {
163
+ "status": "saved",
164
+ "id": memory_id,
165
+ "topic": topic,
166
+ "category": category,
167
+ "tags": tags_str,
168
+ }
169
+ if auto_tags:
170
+ result["auto_tags_added"] = auto_tags
171
+ if duplicates:
172
+ result["possible_duplicates"] = [
173
+ {"id": d["id"], "topic": d["topic"], "exact_match": d.get("exact_match", False)} for d in duplicates
174
+ ]
175
+ return _json(result)
176
+
177
+
178
+ @tool
179
+ def append_memory(memory_id: int, content: str, source: str = "") -> str:
180
+ """Append additional context to an existing memory without replacing it.
181
+ The new content is added with a timestamp separator. History is tracked.
182
+
183
+ Args:
184
+ memory_id: The ID of the memory to append to
185
+ content: New content to add (will be appended with a timestamp)
186
+ source: Where this update came from (default: the connected client's name)
187
+ """
188
+ source = source or _client_name()
189
+ db = store.connect()
190
+ row = _get_memory_row(db, memory_id)
191
+
192
+ now = utc_now()
193
+ separator = f"\n\n---\n**[Update {now.strftime('%Y-%m-%d %H:%M UTC')}]**"
194
+ if source:
195
+ separator += f" *(from {source})*"
196
+ separator += "\n"
197
+
198
+ existing_tags = parse_tags(row["tags"] or "")
199
+ new_tags = [t for t in settings.tags_for(content) if t not in existing_tags]
200
+
201
+ db.execute(
202
+ "UPDATE memories SET content = ?, tags = ?, updated_at = ? WHERE id = ?",
203
+ (row["content"] + separator + content, ",".join(existing_tags + new_tags), now.isoformat(), memory_id),
204
+ )
205
+ db.execute(
206
+ "INSERT INTO memory_history (memory_id, action, content_snippet, created_at) VALUES (?, ?, ?, ?)",
207
+ (memory_id, "appended", content[:100], now.isoformat()),
208
+ )
209
+ db.commit()
210
+
211
+ append_count = db.execute(
212
+ "SELECT COUNT(*) FROM memory_history WHERE memory_id = ? AND action = 'appended'", (memory_id,)
213
+ ).fetchone()[0]
214
+ return _json(
215
+ {
216
+ "status": "appended",
217
+ "id": memory_id,
218
+ "topic": row["topic"],
219
+ "total_appends": append_count,
220
+ "new_tags": new_tags or None,
221
+ }
222
+ )
223
+
224
+
225
+ @tool
226
+ def search_memory(
227
+ query: str,
228
+ category: str = "",
229
+ importance: str = "",
230
+ limit: int = 10,
231
+ include_expired: bool = False,
232
+ full_content: bool = False,
233
+ ) -> str:
234
+ """Search memories by relevance: full-text search with stemming (topic and tag matches rank highest),
235
+ falling back to substring matching. Returns a snippet per memory; use get_memory for the full text.
236
+ Expired memories are excluded by default.
237
+
238
+ Args:
239
+ query: Search query — plain words, hyphenated names, IDs, or FTS5 syntax (e.g. "tags:aws", "datadog OR sentry")
240
+ category: Filter by category (optional)
241
+ importance: Filter by minimum importance: low, normal, high, critical (optional)
242
+ limit: Maximum number of results (default: 10, max: 100)
243
+ include_expired: Set to true to include expired memories (default: false)
244
+ full_content: Set to true to return full content instead of snippets (default: false)
245
+ """
246
+ db = store.connect()
247
+ filter_sql, filter_params = filters(category, importance, include_expired)
248
+ results = search(db, query, filter_sql, filter_params, clamp(limit))
249
+
250
+ if full_content and results:
251
+ ids = [r["id"] for r in results]
252
+ placeholders = ",".join("?" * len(ids))
253
+ contents = dict(db.execute(f"SELECT id, content FROM memories WHERE id IN ({placeholders})", ids).fetchall())
254
+ for r in results:
255
+ r.pop("snippet", None)
256
+ r["content"] = contents[r["id"]]
257
+
258
+ return _json({"count": len(results), "results": results})
259
+
260
+
261
+ @tool
262
+ def list_memories(
263
+ category: str = "", tag: str = "", thread_id: str = "", limit: int = 25, include_expired: bool = False
264
+ ) -> str:
265
+ """List saved memories with optional filters. Expired memories excluded by default.
266
+
267
+ Args:
268
+ category: Filter by category (optional)
269
+ tag: Filter by tag (optional)
270
+ thread_id: Filter by thread ID to see all related memories (optional)
271
+ limit: Maximum number of results (default: 25, max: 200)
272
+ include_expired: Set to true to include expired memories (default: false)
273
+ """
274
+ db = store.connect()
275
+ filter_sql, params = filters(category, include_expired=include_expired)
276
+ sql = (
277
+ "SELECT m.id, m.topic, m.category, m.tags, m.importance, m.thread_id, m.expires_at, m.created_at, "
278
+ f"m.updated_at FROM memories m WHERE 1=1{filter_sql}"
279
+ )
280
+ if tag:
281
+ sql += " AND ',' || m.tags || ',' LIKE ?"
282
+ params.append(f"%,{tag.strip()},%")
283
+ if thread_id:
284
+ sql += " AND m.thread_id = ?"
285
+ params.append(thread_id)
286
+ sql += " ORDER BY m.updated_at DESC LIMIT ?"
287
+ params.append(clamp(limit, 200))
288
+
289
+ results = _rows(db, sql, params)
290
+ return _json({"count": len(results), "results": results})
291
+
292
+
293
+ @tool
294
+ def get_memory(memory_id: int) -> str:
295
+ """Get the full content of a specific memory including linked entities and edit history.
296
+
297
+ Args:
298
+ memory_id: The ID of the memory to retrieve
299
+ """
300
+ db = store.connect()
301
+ result = dict(_get_memory_row(db, memory_id))
302
+ result["is_expired"] = is_expired(result.get("expires_at", ""))
303
+ result["entities"] = _rows(db, "SELECT entity_type, entity_value FROM entities WHERE memory_id = ?", (memory_id,))
304
+ result["history"] = _rows(
305
+ db,
306
+ "SELECT action, content_snippet, created_at FROM memory_history WHERE memory_id = ? ORDER BY created_at",
307
+ (memory_id,),
308
+ )
309
+ return _json(result)
310
+
311
+
312
+ @tool
313
+ def update_memory(
314
+ memory_id: int,
315
+ topic: str = "",
316
+ content: str = "",
317
+ category: str = "",
318
+ tags: str = "",
319
+ importance: str = "",
320
+ thread_id: str = "",
321
+ expires_at: str = "",
322
+ ) -> str:
323
+ """Update an existing memory. Only provided fields are updated. Use append_memory to add context without replacing.
324
+ Replaced content is kept in the memory's history (see get_memory_history).
325
+
326
+ Args:
327
+ memory_id: The ID of the memory to update
328
+ topic: New topic (leave empty to keep existing)
329
+ content: New content (leave empty to keep existing)
330
+ category: New category (leave empty to keep existing)
331
+ tags: New tags (leave empty to keep existing)
332
+ importance: New importance (leave empty to keep existing)
333
+ thread_id: New thread ID (leave empty to keep existing)
334
+ expires_at: New expiry (leave empty to keep existing)
335
+ """
336
+ if category:
337
+ check_category(category)
338
+ if importance:
339
+ check_importance(importance)
340
+ if expires_at:
341
+ check_expiry(expires_at)
342
+
343
+ db = store.connect()
344
+ row = _get_memory_row(db, memory_id)
345
+ fields = {
346
+ "topic": topic,
347
+ "content": content,
348
+ "category": category,
349
+ "tags": tags,
350
+ "importance": importance,
351
+ "thread_id": thread_id,
352
+ "expires_at": expires_at,
353
+ }
354
+ updates = {k: v or row[k] for k, v in fields.items()}
355
+ changed = [k for k in fields if updates[k] != row[k]]
356
+ if not changed:
357
+ return _json({"status": "unchanged", "id": memory_id, "topic": row["topic"]})
358
+
359
+ now = utc_now().isoformat()
360
+ db.execute(
361
+ """UPDATE memories SET topic=?, content=?, category=?, tags=?, importance=?,
362
+ thread_id=?, expires_at=?, updated_at=? WHERE id=?""",
363
+ (*updates.values(), now, memory_id),
364
+ )
365
+ db.execute(
366
+ """INSERT INTO memory_history (memory_id, action, content_snippet, created_at, previous_content)
367
+ VALUES (?, ?, ?, ?, ?)""",
368
+ (memory_id, "updated", f"fields: {','.join(changed)}", now, row["content"] if "content" in changed else ""),
369
+ )
370
+ db.commit()
371
+ return _json({"status": "updated", "id": memory_id, "topic": updates["topic"], "changed": changed})
372
+
373
+
374
+ @tool
375
+ def delete_memory(memory_id: int) -> str:
376
+ """Delete a memory and its linked entities.
377
+
378
+ Args:
379
+ memory_id: The ID of the memory to delete
380
+ """
381
+ db = store.connect()
382
+ row = _get_memory_row(db, memory_id, "topic")
383
+ db.execute("DELETE FROM memories WHERE id = ?", (memory_id,))
384
+ db.commit()
385
+ return _json({"status": "deleted", "id": memory_id, "topic": row["topic"]})
386
+
387
+
388
+ # ─── Entity tools ────────────────────────────────────────────────────
389
+
390
+
391
+ @tool
392
+ def find_by_entity(entity_type: str = "", entity_value: str = "", limit: int = 25) -> str:
393
+ """Find all memories linked to a specific entity (person, service, account, etc.).
394
+
395
+ Args:
396
+ entity_type: Type of entity (e.g. "person", "service", "account", "bucket", "ticket")
397
+ entity_value: Value to search, substring match (e.g. "alice", "billing-api", "prod-db")
398
+ limit: Maximum results (default: 25, max: 200)
399
+ """
400
+ db = store.connect()
401
+ filter_sql, params = filters()
402
+ sql = f"""
403
+ SELECT DISTINCT m.id, m.topic, m.category, m.tags, m.importance, m.thread_id, m.expires_at, m.created_at
404
+ FROM memories m
405
+ JOIN entities e ON e.memory_id = m.id
406
+ WHERE 1=1{filter_sql}
407
+ """
408
+ if entity_type:
409
+ sql += " AND e.entity_type = ?"
410
+ params.append(entity_type.strip().lower())
411
+ if entity_value:
412
+ sql += " AND e.entity_value LIKE ?"
413
+ params.append(f"%{entity_value.strip().lower()}%")
414
+ sql += " ORDER BY m.updated_at DESC LIMIT ?"
415
+ params.append(clamp(limit, 200))
416
+
417
+ results = _rows(db, sql, params)
418
+ return _json({"count": len(results), "results": results})
419
+
420
+
421
+ @tool
422
+ def list_entities(entity_type: str = "") -> str:
423
+ """List all unique entities, optionally filtered by type. Useful to see what/who has been referenced.
424
+
425
+ Args:
426
+ entity_type: Filter by type (optional, e.g. "person", "service")
427
+ """
428
+ db = store.connect()
429
+ select = "SELECT entity_type, entity_value, COUNT(*) AS mention_count FROM entities"
430
+ if entity_type:
431
+ results = _rows(
432
+ db,
433
+ f"{select} WHERE entity_type = ? GROUP BY entity_type, entity_value ORDER BY mention_count DESC",
434
+ (entity_type.strip().lower(),),
435
+ )
436
+ else:
437
+ results = _rows(db, f"{select} GROUP BY entity_type, entity_value ORDER BY entity_type, mention_count DESC")
438
+ return _json({"count": len(results), "results": results})
439
+
440
+
441
+ # ─── Thread and timeline tools ───────────────────────────────────────
442
+
443
+
444
+ @tool
445
+ def get_thread(thread_id: str) -> str:
446
+ """Get all memories in a conversation thread, in chronological order.
447
+
448
+ Args:
449
+ thread_id: The thread ID to retrieve (e.g. "incident-2026-03-24", "stock-analysis-march")
450
+ """
451
+ db = store.connect()
452
+ results = _rows(
453
+ db,
454
+ """SELECT id, topic, content, category, tags, importance, created_at, updated_at
455
+ FROM memories WHERE thread_id = ? ORDER BY created_at""",
456
+ (thread_id,),
457
+ )
458
+ return _json({"thread_id": thread_id, "count": len(results), "results": results})
459
+
460
+
461
+ @tool
462
+ def list_threads() -> str:
463
+ """List all conversation threads with their memory counts and latest activity."""
464
+ db = store.connect()
465
+ results = _rows(
466
+ db,
467
+ """SELECT thread_id, COUNT(*) AS memory_count, MIN(created_at) AS first_memory,
468
+ MAX(updated_at) AS last_activity, GROUP_CONCAT(DISTINCT category) AS categories
469
+ FROM memories WHERE thread_id != '' GROUP BY thread_id ORDER BY last_activity DESC""",
470
+ )
471
+ return _json({"count": len(results), "threads": results})
472
+
473
+
474
+ @tool
475
+ def get_daily_summary(date: str = "") -> str:
476
+ """Get memories created or updated on a specific local date, with new ones grouped by category.
477
+
478
+ Args:
479
+ date: Date in YYYY-MM-DD format, in this machine's local timezone (default: today)
480
+ """
481
+ try:
482
+ day = datetime.strptime(date, "%Y-%m-%d").date() if date else datetime.now().date()
483
+ except ValueError:
484
+ raise ToolError("Invalid date. Use YYYY-MM-DD") from None
485
+ start = datetime.combine(day, time.min).astimezone().astimezone(timezone.utc).isoformat()
486
+ end = datetime.combine(day + timedelta(days=1), time.min).astimezone().astimezone(timezone.utc).isoformat()
487
+
488
+ db = store.connect()
489
+ rows = _rows(
490
+ db,
491
+ """SELECT id, topic, category, tags, importance, thread_id, created_at, updated_at
492
+ FROM memories
493
+ WHERE (created_at >= ? AND created_at < ?) OR (updated_at >= ? AND updated_at < ?)
494
+ ORDER BY category, created_at""",
495
+ (start, end, start, end),
496
+ )
497
+ grouped: dict[str, list[dict[str, Any]]] = {}
498
+ updated = []
499
+ for r in rows:
500
+ if start <= r["created_at"] < end:
501
+ grouped.setdefault(r["category"], []).append(r)
502
+ else:
503
+ updated.append(r)
504
+
505
+ return _json(
506
+ {
507
+ "date": day.isoformat(),
508
+ "total": sum(len(v) for v in grouped.values()),
509
+ "by_category": grouped,
510
+ "updated_existing": updated,
511
+ }
512
+ )
513
+
514
+
515
+ @tool
516
+ def get_memory_history(memory_id: int) -> str:
517
+ """Get the full edit/append history of a memory, including content replaced by update_memory.
518
+
519
+ Args:
520
+ memory_id: The ID of the memory
521
+ """
522
+ db = store.connect()
523
+ row = _get_memory_row(db, memory_id, "id, topic")
524
+ history = _rows(
525
+ db,
526
+ """SELECT action, content_snippet, previous_content, created_at
527
+ FROM memory_history WHERE memory_id = ? ORDER BY created_at""",
528
+ (memory_id,),
529
+ )
530
+ for h in history:
531
+ if not h["previous_content"]:
532
+ h.pop("previous_content")
533
+ return _json({"memory_id": memory_id, "topic": row["topic"], "history_count": len(history), "history": history})
534
+
535
+
536
+ # ─── Maintenance tools ───────────────────────────────────────────────
537
+
538
+
539
+ @tool
540
+ def cleanup_expired(dry_run: bool = True) -> str:
541
+ """Find and optionally delete expired memories.
542
+
543
+ Args:
544
+ dry_run: If true (default), only lists expired memories without deleting. Set to false to actually delete.
545
+ """
546
+ db = store.connect()
547
+ expired = _rows(db, "SELECT id, topic, category, importance, expires_at FROM memories WHERE is_expired(expires_at)")
548
+ if not expired:
549
+ return _json({"status": "clean", "message": "No expired memories found"})
550
+ if dry_run:
551
+ return _json(
552
+ {
553
+ "status": "dry_run",
554
+ "expired_count": len(expired),
555
+ "expired": expired,
556
+ "message": "Call with dry_run=false to delete these memories",
557
+ }
558
+ )
559
+
560
+ deleted_ids = [m["id"] for m in expired]
561
+ db.executemany("DELETE FROM memories WHERE id = ?", [(i,) for i in deleted_ids])
562
+ db.commit()
563
+ return _json({"status": "cleaned", "deleted_count": len(deleted_ids), "deleted_ids": deleted_ids})
564
+
565
+
566
+ @tool
567
+ def find_duplicates(topic: str = "", limit: int = 10) -> str:
568
+ """Find potentially duplicate memories. If topic is provided, finds memories similar to it.
569
+ If no topic, finds groups of memories whose topics share most of their significant words.
570
+
571
+ Args:
572
+ topic: Topic to check for duplicates (optional — if empty, scans all memories)
573
+ limit: Maximum results or groups (default: 10, max: 100)
574
+ """
575
+ db = store.connect()
576
+ limit = clamp(limit)
577
+ if topic:
578
+ duplicates = find_similar(db, topic, limit)
579
+ return _json({"query_topic": topic, "potential_duplicates": duplicates, "count": len(duplicates)})
580
+
581
+ memories = _rows(db, "SELECT id, topic, category, importance, created_at FROM memories ORDER BY created_at")
582
+ groups = group_similar(memories, limit)
583
+ return _json({"duplicate_groups": groups, "group_count": len(groups)})
584
+
585
+
586
+ @tool
587
+ def get_stats() -> str:
588
+ """Get overview stats: total memories, breakdown by category, importance, top entities, active threads, expired count."""
589
+ db = store.connect()
590
+
591
+ def count(sql: str) -> int:
592
+ return int(db.execute(sql).fetchone()[0])
593
+
594
+ return _json(
595
+ {
596
+ "db_path": store.path,
597
+ "total_memories": count("SELECT COUNT(*) FROM memories"),
598
+ "expired_memories": count("SELECT COUNT(*) FROM memories WHERE is_expired(expires_at)"),
599
+ "total_appends": count("SELECT COUNT(*) FROM memory_history WHERE action = 'appended'"),
600
+ "by_category": _rows(db, "SELECT category, COUNT(*) AS c FROM memories GROUP BY category ORDER BY c DESC"),
601
+ "by_importance": _rows(
602
+ db, "SELECT importance, COUNT(*) AS c FROM memories GROUP BY importance ORDER BY c DESC"
603
+ ),
604
+ "top_entities": _rows(
605
+ db,
606
+ """SELECT entity_type, entity_value, COUNT(*) AS c FROM entities
607
+ GROUP BY entity_type, entity_value ORDER BY c DESC LIMIT 15""",
608
+ ),
609
+ "active_threads": _rows(
610
+ db,
611
+ """SELECT thread_id, COUNT(*) AS c FROM memories WHERE thread_id != ''
612
+ GROUP BY thread_id ORDER BY MAX(updated_at) DESC LIMIT 10""",
613
+ ),
614
+ "recent_memories": _rows(
615
+ db, "SELECT id, topic, category, importance, created_at FROM memories ORDER BY created_at DESC LIMIT 5"
616
+ ),
617
+ }
618
+ )