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/__init__.py +5 -0
- agentmemdb/__main__.py +3 -0
- agentmemdb/cli.py +54 -0
- agentmemdb/config.py +92 -0
- agentmemdb/icon.svg +30 -0
- agentmemdb/models.py +93 -0
- agentmemdb/py.typed +0 -0
- agentmemdb/search.py +156 -0
- agentmemdb/server.py +618 -0
- agentmemdb/storage.py +136 -0
- agentmemdb-0.1.0.dist-info/METADATA +163 -0
- agentmemdb-0.1.0.dist-info/RECORD +16 -0
- agentmemdb-0.1.0.dist-info/WHEEL +5 -0
- agentmemdb-0.1.0.dist-info/entry_points.txt +2 -0
- agentmemdb-0.1.0.dist-info/licenses/LICENSE +21 -0
- agentmemdb-0.1.0.dist-info/top_level.txt +1 -0
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
|
+
)
|