agent-memory-brain 0.2.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.
- agent_memory_brain-0.2.0.dist-info/METADATA +98 -0
- agent_memory_brain-0.2.0.dist-info/RECORD +54 -0
- agent_memory_brain-0.2.0.dist-info/WHEEL +4 -0
- agent_memory_brain-0.2.0.dist-info/entry_points.txt +3 -0
- agent_memory_brain-0.2.0.dist-info/licenses/LICENSE +67 -0
- devmem/__init__.py +3 -0
- devmem/ado.py +433 -0
- devmem/agent_colors.py +74 -0
- devmem/cli.py +500 -0
- devmem/db.py +1069 -0
- devmem/dispatch.py +478 -0
- devmem/embed.py +111 -0
- devmem/filelock.py +53 -0
- devmem/git_sync.py +261 -0
- devmem/guard.py +214 -0
- devmem/hook_log.py +49 -0
- devmem/ingest/__init__.py +1 -0
- devmem/ingest/claude.py +87 -0
- devmem/ingest/codex.py +175 -0
- devmem/ingest/runner.py +318 -0
- devmem/ingest/subagents.py +250 -0
- devmem/maintenance.py +801 -0
- devmem/mcp_server.py +542 -0
- devmem/messages.py +684 -0
- devmem/panel/__init__.py +1 -0
- devmem/panel/app.py +971 -0
- devmem/panel/state.py +358 -0
- devmem/panel/static/base.css +27 -0
- devmem/panel/static/brain.css +473 -0
- devmem/panel/static/brain.js +1118 -0
- devmem/panel/static/brain3d.js +600 -0
- devmem/panel/static/chat.js +18 -0
- devmem/panel/static/common.js +208 -0
- devmem/panel/static/site.css +508 -0
- devmem/panel/static/tokens.css +19 -0
- devmem/panel/templates/ado.html +62 -0
- devmem/panel/templates/agents.html +35 -0
- devmem/panel/templates/audit.html +19 -0
- devmem/panel/templates/base.html +42 -0
- devmem/panel/templates/brain.html +101 -0
- devmem/panel/templates/maintenance.html +32 -0
- devmem/panel/templates/memory.html +71 -0
- devmem/panel/templates/message_thread.html +49 -0
- devmem/panel/templates/messages.html +8 -0
- devmem/panel/templates/overview.html +109 -0
- devmem/panel/templates/project.html +48 -0
- devmem/panel/templates/projects.html +21 -0
- devmem/panel/templates/session.html +49 -0
- devmem/panel/templates/sessions.html +67 -0
- devmem/panel/templates/subagent.html +27 -0
- devmem/procs.py +10 -0
- devmem/projects.py +17 -0
- devmem/secrets.py +9 -0
- devmem/skills.py +105 -0
devmem/mcp_server.py
ADDED
|
@@ -0,0 +1,542 @@
|
|
|
1
|
+
"""stdio MCP server. Memory is intentionally plain SQLite, never an external service."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import atexit
|
|
6
|
+
import errno
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
import os
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
import re
|
|
12
|
+
import sqlite3
|
|
13
|
+
import socket
|
|
14
|
+
import sys
|
|
15
|
+
import time
|
|
16
|
+
from typing import Any
|
|
17
|
+
from urllib.error import HTTPError, URLError
|
|
18
|
+
from urllib.request import HTTPRedirectHandler, ProxyHandler, Request, build_opener
|
|
19
|
+
|
|
20
|
+
from mcp.server.fastmcp import Context, FastMCP
|
|
21
|
+
|
|
22
|
+
from . import messages
|
|
23
|
+
from .guard import rules_data, warnings as guard_warnings
|
|
24
|
+
from .skills import list_skills
|
|
25
|
+
|
|
26
|
+
log = logging.getLogger("devmem.mcp")
|
|
27
|
+
from .db import connection, context_text, embed_memory, hybrid_search, is_stale, not_superseded_sql, now, project_for_cwd, restore_memory, save_memory, similar_memories, task_board as read_task_board, task_status_set as set_task_status, update_memory, validate_content, validate_review_after
|
|
28
|
+
|
|
29
|
+
_INSTRUCTIONS = "Local long-term coding memory. Search before non-trivial work; if project search or list is empty, retry scope='all' because the server cwd may not be the repo. After memory_save, inspect similar; if it is the same fact, update the old memory and delete the new one. Prefer memory_update when facts change; history is available through memory_history and memory_restore. Link related memories instead of repeating their content. Pinned memories are already in the briefing, so pin only what every session needs. Never store secrets, credentials, or private keys."
|
|
30
|
+
_MENTION = re.compile(r"(?<![\w.])@[A-Za-z][\w-]*")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _mcp_instructions() -> str:
|
|
34
|
+
"""Add global text rules at startup without creating or requiring a database."""
|
|
35
|
+
try:
|
|
36
|
+
path = Path(os.environ.get("DEVMEM_DB", "~/.devmem/devmem.db")).expanduser().resolve()
|
|
37
|
+
if not path.is_file():
|
|
38
|
+
return _INSTRUCTIONS
|
|
39
|
+
with sqlite3.connect(path.as_uri() + "?mode=ro", uri=True) as conn:
|
|
40
|
+
conn.row_factory = sqlite3.Row
|
|
41
|
+
rules = rules_data(conn, None)["text_rules"]
|
|
42
|
+
text = "\n".join(f"- {rule['text']} (devmem #{rule['id']}: {rule['title']})" for rule in rules)
|
|
43
|
+
addition = "\n\n## Rules\n" + text
|
|
44
|
+
return _INSTRUCTIONS + (addition[:2000] if text else "")
|
|
45
|
+
except Exception:
|
|
46
|
+
return _INSTRUCTIONS
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
mcp = FastMCP("devmem", instructions=_mcp_instructions())
|
|
50
|
+
_client_id: int | None = None
|
|
51
|
+
_client_name: str | None = None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class _PanelNoRedirect(HTTPRedirectHandler):
|
|
55
|
+
"""Keep a panel redirect from causing an unreviewed follow-up request."""
|
|
56
|
+
|
|
57
|
+
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# This opener is deliberately used only by the two fixed loopback URLs below.
|
|
62
|
+
_PANEL_OPENER = build_opener(ProxyHandler({}), _PanelNoRedirect())
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _is_timeout(exc: BaseException) -> bool:
|
|
66
|
+
reason = exc.reason if isinstance(exc, URLError) else exc
|
|
67
|
+
return isinstance(reason, (TimeoutError, socket.timeout))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _is_connection_refused(exc: URLError) -> bool:
|
|
71
|
+
reason = exc.reason
|
|
72
|
+
return isinstance(reason, ConnectionRefusedError) or getattr(reason, "errno", None) == errno.ECONNREFUSED
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _panel_urls() -> tuple[str, str]:
|
|
76
|
+
"""Return the only panel URLs this process is permitted to request."""
|
|
77
|
+
raw_port = os.environ.get("DEVMEM_PANEL_PORT", "8765")
|
|
78
|
+
try:
|
|
79
|
+
port = int(raw_port)
|
|
80
|
+
except ValueError:
|
|
81
|
+
port = 8765
|
|
82
|
+
if not 1 <= port <= 65535:
|
|
83
|
+
port = 8765
|
|
84
|
+
base = f"http://127.0.0.1:{port}"
|
|
85
|
+
return base + "/api/ado", base + "/ado/refresh"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _panel_snapshot(refresh: bool) -> dict[str, Any]:
|
|
89
|
+
"""Read the panel-owned ADO cache without following any redirect."""
|
|
90
|
+
api_url, refresh_url = _panel_urls()
|
|
91
|
+
refresh_timed_out = False
|
|
92
|
+
if refresh:
|
|
93
|
+
try:
|
|
94
|
+
with _PANEL_OPENER.open(Request(refresh_url, method="POST"), timeout=20) as response:
|
|
95
|
+
# A direct 2xx response is also harmless if the panel implementation changes.
|
|
96
|
+
status = getattr(response, "status", None)
|
|
97
|
+
if status is None:
|
|
98
|
+
status = response.getcode()
|
|
99
|
+
if status not in (200, 201, 202, 204):
|
|
100
|
+
raise OSError("panel refresh failed")
|
|
101
|
+
except HTTPError as exc:
|
|
102
|
+
# The panel normally returns this redirect. It is intentionally not followed.
|
|
103
|
+
if exc.code != 303:
|
|
104
|
+
raise
|
|
105
|
+
except (TimeoutError, socket.timeout):
|
|
106
|
+
refresh_timed_out = True
|
|
107
|
+
except URLError as exc:
|
|
108
|
+
if not _is_timeout(exc):
|
|
109
|
+
raise
|
|
110
|
+
refresh_timed_out = True
|
|
111
|
+
with _PANEL_OPENER.open(Request(api_url, method="GET"), timeout=3) as response:
|
|
112
|
+
payload = response.read().decode("utf-8")
|
|
113
|
+
data = json.loads(payload)
|
|
114
|
+
if not isinstance(data, dict):
|
|
115
|
+
raise ValueError("panel returned an invalid snapshot")
|
|
116
|
+
if refresh_timed_out:
|
|
117
|
+
# This private marker is removed before the MCP response is produced.
|
|
118
|
+
data["_refresh_timed_out"] = True
|
|
119
|
+
return data
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _ado_unavailable(reason: str) -> dict[str, Any]:
|
|
123
|
+
return {"available": False, "reason": reason}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _ado_pr(pr: dict[str, Any], pending_reviewers: bool = False) -> dict[str, Any]:
|
|
127
|
+
item = {key: pr.get(key) for key in ("id", "title", "repo", "url", "age_label")}
|
|
128
|
+
if pending_reviewers:
|
|
129
|
+
item["pending_reviewers"] = list(pr.get("pending_reviewers") or [])
|
|
130
|
+
return item
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _ado_status(snapshot: dict[str, Any], section: str) -> dict[str, Any]:
|
|
134
|
+
if snapshot.get("pending"):
|
|
135
|
+
return _ado_unavailable("ADO data not loaded yet (first refresh pending)")
|
|
136
|
+
if not snapshot.get("configured"):
|
|
137
|
+
missing = snapshot.get("missing")
|
|
138
|
+
reasons = []
|
|
139
|
+
if snapshot.get("config_error"):
|
|
140
|
+
reasons.append("ADO config error: " + str(snapshot["config_error"]))
|
|
141
|
+
if snapshot.get("token_error"):
|
|
142
|
+
reasons.append("ADO token error: " + str(snapshot["token_error"]))
|
|
143
|
+
if isinstance(missing, list):
|
|
144
|
+
labels = []
|
|
145
|
+
if "token" in missing: labels.append("token")
|
|
146
|
+
if "config" in missing: labels.append("organization/project")
|
|
147
|
+
if labels:
|
|
148
|
+
reasons.append("ADO not configured (missing " + " and ".join(labels) + ")")
|
|
149
|
+
if snapshot.get("error"):
|
|
150
|
+
reasons.append("ADO error: " + str(snapshot["error"]))
|
|
151
|
+
if reasons:
|
|
152
|
+
return _ado_unavailable("; ".join(reasons))
|
|
153
|
+
return _ado_unavailable("ADO not configured (missing token or organization/project; see README 'Azure DevOps')")
|
|
154
|
+
result: dict[str, Any] = {
|
|
155
|
+
"available": True,
|
|
156
|
+
"refreshed_at": snapshot.get("refreshed_at"),
|
|
157
|
+
"stale": bool(snapshot.get("stale")),
|
|
158
|
+
"error": snapshot.get("error"),
|
|
159
|
+
"project_active_count": snapshot.get("project_active_count", 0),
|
|
160
|
+
}
|
|
161
|
+
if section in ("all", "waiting_for_me"):
|
|
162
|
+
result["waiting_for_me"] = {
|
|
163
|
+
"to_review": [_ado_pr(pr) for pr in snapshot.get("to_review", [])],
|
|
164
|
+
"approved_not_completed": [_ado_pr(pr) for pr in snapshot.get("my_approved_not_completed", [])],
|
|
165
|
+
"changes_requested": [_ado_pr(pr) for pr in snapshot.get("my_changes_requested", [])],
|
|
166
|
+
}
|
|
167
|
+
if section in ("all", "waiting_on_others"):
|
|
168
|
+
result["waiting_on_others"] = [
|
|
169
|
+
_ado_pr(pr, pending_reviewers=True) for pr in snapshot.get("my_waiting_on_others", [])
|
|
170
|
+
]
|
|
171
|
+
if section in ("all", "tasks"):
|
|
172
|
+
tasks = [
|
|
173
|
+
{key: task.get(key) for key in ("id", "title", "type", "state", "url")}
|
|
174
|
+
for task in snapshot.get("open_tasks", [])
|
|
175
|
+
]
|
|
176
|
+
tasks.sort(key=lambda task: (task["state"] != "In Progress", task["state"] or "", task["id"]))
|
|
177
|
+
result["tasks"] = tasks
|
|
178
|
+
result["tasks_by_state"] = {
|
|
179
|
+
state: len(items) for state, items in (snapshot.get("open_tasks_by_state") or {}).items()
|
|
180
|
+
}
|
|
181
|
+
return result
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _client(ctx: Context | None = None) -> tuple[int, str | None]:
|
|
185
|
+
global _client_id, _client_name
|
|
186
|
+
cwd = os.getcwd()
|
|
187
|
+
project = project_for_cwd(cwd)
|
|
188
|
+
info = getattr(getattr(getattr(ctx, "session", None), "client_params", None), "clientInfo", None) if ctx else None
|
|
189
|
+
if _client_id is None:
|
|
190
|
+
with connection() as conn:
|
|
191
|
+
cur = conn.execute("INSERT INTO agent_clients(client_name,client_version,pid,cwd,project,started_at,last_seen_at) VALUES (?,?,?,?,?,?,?)",
|
|
192
|
+
(info.name if info else "mcp-client", info.version if info else None, os.getpid(), cwd, project, now(), now()))
|
|
193
|
+
_client_id = cur.lastrowid
|
|
194
|
+
_client_name = info.name if info else None
|
|
195
|
+
elif info and _client_name is None:
|
|
196
|
+
# The first call may arrive without context; record the initialize clientInfo once it is available.
|
|
197
|
+
with connection() as conn:
|
|
198
|
+
conn.execute("UPDATE agent_clients SET client_name=?,client_version=? WHERE id=?", (info.name, info.version, _client_id))
|
|
199
|
+
_client_name = info.name
|
|
200
|
+
return _client_id, project
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _call(tool: str, args: dict[str, Any], fn, ctx: Context | None = None, started: float | None = None):
|
|
204
|
+
started = started if started is not None else time.monotonic()
|
|
205
|
+
client_id, _ = _client(ctx)
|
|
206
|
+
try:
|
|
207
|
+
result = fn()
|
|
208
|
+
count = len(result) if isinstance(result, list) else 1
|
|
209
|
+
ok, error = (0, result.get("error")) if isinstance(result, dict) and result.get("ok") is False else (1, None)
|
|
210
|
+
return result
|
|
211
|
+
except Exception as exc:
|
|
212
|
+
count, ok, error = 0, 0, str(exc)
|
|
213
|
+
raise
|
|
214
|
+
finally:
|
|
215
|
+
# fix(2026-10-28): bookkeeping only; a locked DB here must neither turn a committed post into an error (the agent re-posts) nor replace the real error.
|
|
216
|
+
try:
|
|
217
|
+
with connection() as conn:
|
|
218
|
+
conn.execute("UPDATE agent_clients SET last_seen_at=? WHERE id=?", (now(), client_id))
|
|
219
|
+
conn.execute("INSERT INTO tool_calls(client_id,tool,args_summary,result_count,duration_ms,ok,error,at) VALUES (?,?,?,?,?,?,?,?)",
|
|
220
|
+
(client_id, tool, json.dumps(args, ensure_ascii=False)[:1000], count, int((time.monotonic()-started)*1000), ok, error, now()))
|
|
221
|
+
except Exception:
|
|
222
|
+
log.warning("could not log the %s tool call", tool, exc_info=True)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _scope(scope: str, project: str | None) -> tuple[str, tuple]:
|
|
226
|
+
if scope == "global": return "project IS NULL", ()
|
|
227
|
+
if scope == "project": return "project=?", (project,)
|
|
228
|
+
if scope == "all": return "1=1", ()
|
|
229
|
+
raise ValueError("scope must be project, global, or all")
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
@mcp.tool(description="Save a durable coding memory and return its id plus optional same-scope duplicate hints; merge a same-fact hint into the old memory. Never save secrets. Content is capped: put long material in a file and save a short summary plus its path. Set review_after (YYYY-MM-DD, usually about 14 days out) for status, handoff, or in-progress notes.")
|
|
233
|
+
def memory_save(content: str, title: str | None = None, kind: str = "note", tags: str | None = None, scope: str = "project", pinned: bool = False, review_after: str | None = None, ctx: Context | None = None) -> dict:
|
|
234
|
+
def work():
|
|
235
|
+
_, project = _client(); selected = None if scope == "global" else project
|
|
236
|
+
if scope not in ("project", "global"): raise ValueError("scope must be project or global")
|
|
237
|
+
if kind not in {"note","decision","preference","snippet","todo"}: raise ValueError("invalid kind")
|
|
238
|
+
validate_content(content)
|
|
239
|
+
review = validate_review_after(review_after)
|
|
240
|
+
with connection() as conn:
|
|
241
|
+
memory_id = save_memory(conn, content=content, title=title, kind=kind, tags=tags or "", project=selected, pinned=pinned, review_after=review, source_agent=_client_name or "mcp-client")
|
|
242
|
+
result = {"id": memory_id}
|
|
243
|
+
if warnings := guard_warnings(content): result["warnings"] = warnings
|
|
244
|
+
conn.commit() # loading the embedding model can take seconds; do not hold the write lock meanwhile
|
|
245
|
+
if os.environ.get("DEVMEM_EMBED") != "0":
|
|
246
|
+
try:
|
|
247
|
+
if embed_memory(conn, memory_id):
|
|
248
|
+
similar = similar_memories(conn, memory_id, selected)
|
|
249
|
+
if similar: result["similar"] = similar
|
|
250
|
+
except Exception as exc: # hints are optional; the memory is already saved
|
|
251
|
+
print(f"devmem: similar check skipped: {type(exc).__name__}: {exc}"[:250], file=sys.stderr)
|
|
252
|
+
return result
|
|
253
|
+
return _call("memory_save", {"title": title, "kind": kind, "scope": scope, "pinned": pinned, "review_after": review_after}, work, ctx)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
@mcp.tool(description="Search before non-trivial work. If project scope is empty, retry scope='all' because the server cwd may differ from the repo. Uses hybrid ranking; match is keyword, semantic, or both.")
|
|
257
|
+
def memory_search(query: str, limit: int = 8, kind: str | None = None, scope: str = "project", ctx: Context | None = None) -> list[dict]:
|
|
258
|
+
def work():
|
|
259
|
+
_, project = _client(); where, params = _scope(scope, project)
|
|
260
|
+
with connection() as conn:
|
|
261
|
+
return hybrid_search(conn, query, where_sql=where, params=params, kind=kind,
|
|
262
|
+
limit=max(1, min(limit, 50)))
|
|
263
|
+
return _call("memory_search", {"query": query, "limit": limit, "kind": kind, "scope": scope}, work, ctx)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
@mcp.tool(description="Fetch a memory by id when a search result needs its full content and linked facts.")
|
|
267
|
+
def memory_get(id: int, ctx: Context | None = None) -> dict | None:
|
|
268
|
+
return _call("memory_get", {"id": id}, lambda: _get(id), ctx)
|
|
269
|
+
|
|
270
|
+
def _get(id: int):
|
|
271
|
+
with connection() as conn:
|
|
272
|
+
row = conn.execute("SELECT * FROM memories WHERE id=?", (id,)).fetchone()
|
|
273
|
+
if not row: return None
|
|
274
|
+
result = dict(row)
|
|
275
|
+
superseded = conn.execute("SELECT from_id FROM memory_links WHERE to_id=? AND type='supersedes' ORDER BY id DESC LIMIT 1", (id,)).fetchone()
|
|
276
|
+
result["superseded_by"] = superseded["from_id"] if superseded else None
|
|
277
|
+
result["revisions"] = conn.execute("SELECT COUNT(*) FROM memory_revisions WHERE memory_id=?", (id,)).fetchone()[0]
|
|
278
|
+
result["links"] = [dict(link) for link in conn.execute("""SELECT l.id,l.type,'out' direction,l.to_id other_id,m.title other_title,l.note
|
|
279
|
+
FROM memory_links l JOIN memories m ON m.id=l.to_id WHERE l.from_id=?
|
|
280
|
+
UNION ALL SELECT l.id,l.type,'in',l.from_id,m.title,l.note
|
|
281
|
+
FROM memory_links l JOIN memories m ON m.id=l.from_id WHERE l.to_id=?
|
|
282
|
+
ORDER BY type,other_id""", (id, id)).fetchall()]
|
|
283
|
+
return result
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
_LINK_TYPES = ("blocks", "depends_on", "relates_to", "overlaps", "fixes", "supersedes")
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
@mcp.tool(description="Link two memories with a typed relation (blocks, depends_on, relates_to, overlaps, fixes, supersedes) so related facts are found together; for supersedes, from_id is the newer memory and supersedes to_id. Use instead of repeating the other memory's content.")
|
|
290
|
+
def memory_link(from_id: int, to_id: int, type: str, note: str = "", ctx: Context | None = None) -> dict:
|
|
291
|
+
def work():
|
|
292
|
+
if from_id == to_id: raise ValueError("cannot link a memory to itself")
|
|
293
|
+
if type not in _LINK_TYPES: raise ValueError("type must be " + ", ".join(_LINK_TYPES))
|
|
294
|
+
with connection() as conn:
|
|
295
|
+
if not conn.execute("SELECT 1 FROM memories WHERE id=?", (from_id,)).fetchone() or not conn.execute("SELECT 1 FROM memories WHERE id=?", (to_id,)).fetchone():
|
|
296
|
+
raise ValueError("both memories must exist")
|
|
297
|
+
conn.execute("""INSERT INTO memory_links(from_id,to_id,type,note,created_at) VALUES(?,?,?,?,?)
|
|
298
|
+
ON CONFLICT(from_id,to_id,type) DO UPDATE SET note=excluded.note""", (from_id, to_id, type, note, now()))
|
|
299
|
+
return dict(conn.execute("SELECT * FROM memory_links WHERE from_id=? AND to_id=? AND type=?", (from_id, to_id, type)).fetchone())
|
|
300
|
+
return _call("memory_link", {"from_id": from_id, "to_id": to_id, "type": type}, work, ctx)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
@mcp.tool(description="Remove one typed link between memories, or every link type between them when type is omitted.")
|
|
304
|
+
def memory_unlink(from_id: int, to_id: int, type: str | None = None, ctx: Context | None = None) -> dict:
|
|
305
|
+
def work():
|
|
306
|
+
if type is not None and type not in _LINK_TYPES: raise ValueError("type must be " + ", ".join(_LINK_TYPES))
|
|
307
|
+
with connection() as conn:
|
|
308
|
+
cur = conn.execute("DELETE FROM memory_links WHERE from_id=? AND to_id=?" + (" AND type=?" if type is not None else ""), (from_id, to_id, type) if type is not None else (from_id, to_id))
|
|
309
|
+
return {"removed": cur.rowcount}
|
|
310
|
+
return _call("memory_unlink", {"from_id": from_id, "to_id": to_id, "type": type}, work, ctx)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
@mcp.tool(description="Correct an existing memory when a fact changes; its prior version is kept in history. Never use for secrets. Content is capped: put long material in a file and save a short summary plus its path. Set review_after (YYYY-MM-DD, usually about 14 days out) for status, handoff, or in-progress notes.")
|
|
314
|
+
def memory_update(id: int, content: str | None = None, title: str | None = None, tags: str | None = None, kind: str | None = None, pinned: bool | None = None, review_after: str | None = None, ctx: Context | None = None) -> dict:
|
|
315
|
+
def work():
|
|
316
|
+
with connection() as conn:
|
|
317
|
+
update_memory(conn, id, content=content, title=title, tags=tags, kind=kind, pinned=pinned, review_after=review_after)
|
|
318
|
+
result = _get(id) or {"id": id, "missing": True}
|
|
319
|
+
if content is not None and (warnings := guard_warnings(content)): result["warnings"] = warnings
|
|
320
|
+
return result
|
|
321
|
+
return _call("memory_update", {"id":id, "pinned":pinned, "review_after":review_after}, work, ctx)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
@mcp.tool(description="Use this instead of two memory_update calls when a tracked task's phase changes. It sets the board entry's PHASE and the board's 'Status as of' date. When status is given, it also writes the task memory's 'STATUS (date): ...' line; that memory is entry_id, or the single memory titled '<entry_title_prefix> <task> ...' in the board's project. All or nothing and committed on success; returns ok:false with an error instead of raising for expected failures; dry_run previews without writing.")
|
|
325
|
+
def task_status_set(board_id: int, task: str, phase: str, status: str | None = None, entry_id: int | None = None,
|
|
326
|
+
entry_title_prefix: str = "Phase 2 task", dry_run: bool = False, ctx: Context | None = None) -> dict:
|
|
327
|
+
def work():
|
|
328
|
+
with connection() as conn:
|
|
329
|
+
return set_task_status(conn, board_id, task, phase, status=status, entry_id=entry_id,
|
|
330
|
+
entry_title_prefix=entry_title_prefix, dry_run=dry_run)
|
|
331
|
+
return _call("task_status_set", {"board_id": board_id, "task": task, "entry_id": entry_id, "dry_run": dry_run}, work, ctx)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
@mcp.tool(description="Return a task board's numbered entries (section, n, task, title, phase) and its 'Status as of' date.")
|
|
335
|
+
def task_board(board_id: int, ctx: Context | None = None) -> dict:
|
|
336
|
+
return _call("task_board", {"board_id": board_id}, lambda: _task_board(board_id), ctx)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _task_board(board_id: int) -> dict:
|
|
340
|
+
with connection() as conn:
|
|
341
|
+
return read_task_board(conn, board_id)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
@mcp.tool(description="Delete a memory only when it is obsolete, duplicate, or incorrect.")
|
|
345
|
+
def memory_delete(id: int, ctx: Context | None = None) -> dict:
|
|
346
|
+
def work():
|
|
347
|
+
with connection() as conn: cur = conn.execute("DELETE FROM memories WHERE id=?", (id,))
|
|
348
|
+
return {"deleted": bool(cur.rowcount)}
|
|
349
|
+
return _call("memory_delete", {"id":id}, work, ctx)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
@mcp.tool(description="Return prior versions of a memory, newest first, including after deletion; use before restoring or auditing a change.")
|
|
353
|
+
def memory_history(id: int, limit: int = 10, ctx: Context | None = None) -> list[dict]:
|
|
354
|
+
def work():
|
|
355
|
+
with connection() as conn:
|
|
356
|
+
return [dict(row) for row in conn.execute("""SELECT id,op,revised_at,title,kind,project,tags,pinned,created_at,content
|
|
357
|
+
FROM memory_revisions WHERE memory_id=? ORDER BY id DESC LIMIT ?""", (id, max(1, min(limit, 50)))).fetchall()]
|
|
358
|
+
return _call("memory_history", {"id": id, "limit": limit}, work, ctx)
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
@mcp.tool(description="Restore one history revision and return the restored memory; use to undo an update or recover a deleted memory.")
|
|
362
|
+
def memory_restore(id: int, revision_id: int, ctx: Context | None = None) -> dict:
|
|
363
|
+
def work():
|
|
364
|
+
with connection() as conn:
|
|
365
|
+
restore_memory(conn, id, revision_id, _client_name or "mcp-client")
|
|
366
|
+
return _get(id)
|
|
367
|
+
return _call("memory_restore", {"id": id, "revision_id": revision_id}, work, ctx)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
@mcp.tool(description="List recent memories for orientation; if project scope is empty, retry scope='all'. Search is better for a specific question.")
|
|
371
|
+
def memory_list(limit: int = 20, kind: str | None = None, scope: str = "project", ctx: Context | None = None) -> list[dict]:
|
|
372
|
+
def work():
|
|
373
|
+
_, project = _client(); where, params = _scope(scope, project)
|
|
374
|
+
if kind: where += " AND kind=?"; params += (kind,)
|
|
375
|
+
with connection() as conn:
|
|
376
|
+
return [{**dict(r), "stale": is_stale(r["review_after"])} for r in conn.execute(f"""SELECT id,title,kind,project,tags,pinned,review_after,updated_at FROM memories
|
|
377
|
+
WHERE {where} AND {not_superseded_sql('memories')} ORDER BY updated_at DESC LIMIT ?""", (*params, max(1,min(limit,100)))).fetchall()]
|
|
378
|
+
return _call("memory_list", {"limit":limit,"kind":kind,"scope":scope}, work, ctx)
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
@mcp.tool(description="List binding devmem guard rules, invalid GUARD lines and notices. Pass cwd as the repo path; it defaults to the server's cwd, which may not be the repo, and then only global rules appear. Codex is not covered by the hook, so Codex task specs must include the output of `devmem rules --codex --cwd <repo>`.")
|
|
382
|
+
def rules_list(cwd: str | None = None, ctx: Context | None = None) -> dict:
|
|
383
|
+
def work():
|
|
384
|
+
with connection() as conn: return rules_data(conn, cwd or os.getcwd())
|
|
385
|
+
return _call("rules_list", {"cwd": cwd}, work, ctx)
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
@mcp.tool(description="List devmem coding skills; agents should follow briefing skills when coding.")
|
|
389
|
+
def skills_list(ctx: Context | None = None) -> dict:
|
|
390
|
+
return _call("skills_list", {}, list_skills, ctx)
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
@mcp.tool(description="Get a compact briefing before non-trivial work: all pinned memories, recent project context, and global preferences.")
|
|
394
|
+
def project_context(ctx: Context | None = None) -> str:
|
|
395
|
+
def work():
|
|
396
|
+
_, project = _client()
|
|
397
|
+
with connection() as conn:
|
|
398
|
+
return context_text(conn, project)
|
|
399
|
+
return _call("project_context", {}, work, ctx)
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
@mcp.tool(description="Return the latest automatic-maintenance run and unresolved flags for the current project. This tool never starts maintenance.")
|
|
403
|
+
def maintenance_status(ctx: Context | None = None) -> dict:
|
|
404
|
+
def work():
|
|
405
|
+
from .maintenance import OPEN_FLAG_SQL, attention
|
|
406
|
+
_, project = _client()
|
|
407
|
+
with connection() as conn:
|
|
408
|
+
last = conn.execute("SELECT * FROM maintenance_runs WHERE status NOT IN ('requested','done','skipped') ORDER BY id DESC LIMIT 1").fetchone()
|
|
409
|
+
flags = conn.execute(f"""SELECT c.id,c.memory_id,c.target_id,c.summary,c.blocked_by,c.new_title,c.new_content,c.created_at,m.title memory_title
|
|
410
|
+
FROM maintenance_changes c JOIN memories m ON m.id=c.memory_id
|
|
411
|
+
WHERE {OPEN_FLAG_SQL} AND (m.project=? OR m.project IS NULL) ORDER BY c.id DESC LIMIT 50""", (project,)).fetchall()
|
|
412
|
+
return {"last_run": dict(last) if last else None, "pending_flags": [dict(row) for row in flags], "failing": attention(conn)["failing_since"]}
|
|
413
|
+
return _call("maintenance_status", {}, work, ctx)
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
@mcp.tool(description="Read-only Azure DevOps summary served from the running devmem panel's cache (5-min refresh). section: all | waiting_for_me (PRs to review, own PRs approved-not-completed or with changes requested) | waiting_on_others | tasks (open work items assigned to you in the configured project). refresh=true forces a live fetch (slow). Returns available:false with a reason if the panel is down, ADO is unconfigured, or data is not loaded yet.")
|
|
417
|
+
def ado_status(section: str = "all", refresh: bool = False, ctx: Context | None = None) -> dict:
|
|
418
|
+
"""Return a compact, token-free projection of the running panel's ADO cache."""
|
|
419
|
+
def work():
|
|
420
|
+
if section not in {"all", "waiting_for_me", "waiting_on_others", "tasks"}:
|
|
421
|
+
raise ValueError("section must be all, waiting_for_me, waiting_on_others, or tasks")
|
|
422
|
+
try:
|
|
423
|
+
snapshot = _panel_snapshot(refresh)
|
|
424
|
+
except HTTPError as exc:
|
|
425
|
+
if exc.code != 303:
|
|
426
|
+
return _ado_unavailable(f"panel returned HTTP {exc.code}")
|
|
427
|
+
return _ado_unavailable("panel refresh redirected unexpectedly")
|
|
428
|
+
except URLError as exc:
|
|
429
|
+
port = _panel_urls()[0].rsplit(":", 1)[1].split("/", 1)[0]
|
|
430
|
+
if _is_connection_refused(exc):
|
|
431
|
+
return _ado_unavailable(f"devmem panel not running on 127.0.0.1:{port}")
|
|
432
|
+
if _is_timeout(exc):
|
|
433
|
+
return _ado_unavailable("panel request timed out")
|
|
434
|
+
return _ado_unavailable(f"panel request failed ({type(exc.reason).__name__})")
|
|
435
|
+
except (TimeoutError, socket.timeout):
|
|
436
|
+
return _ado_unavailable("panel request timed out")
|
|
437
|
+
except (OSError, ValueError, UnicodeDecodeError, json.JSONDecodeError):
|
|
438
|
+
return _ado_unavailable("panel returned an invalid snapshot")
|
|
439
|
+
refresh_timed_out = bool(snapshot.pop("_refresh_timed_out", False))
|
|
440
|
+
result = _ado_status(snapshot, section)
|
|
441
|
+
if refresh_timed_out:
|
|
442
|
+
result["note"] = "refresh timed out; showing cached data"
|
|
443
|
+
return result
|
|
444
|
+
return _call("ado_status", {"section": section, "refresh": refresh}, work, ctx)
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
# ---------------------------------------------------------------- messages between agents
|
|
448
|
+
|
|
449
|
+
WAIT_POLL_S = 2.0
|
|
450
|
+
WAIT_MAX_S = 60
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def _me(sender: str | None) -> str:
|
|
454
|
+
"""Subagents share the parent's MCP connection, so only a self-declared name tells them apart."""
|
|
455
|
+
return (sender or "").strip() or _client_name or "mcp-client"
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def _sender_kind(declared: bool) -> str:
|
|
459
|
+
if "codex" in (_client_name or "").lower(): return "codex"
|
|
460
|
+
return "subagent" if declared else "claude"
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
@mcp.tool(description="Post a message, finding, question or hand-off for other agents and the user (they see it in the devmem panel). subject: one short line saying what happened, for example 'tests pass, 2 fixes left' — this is what the user reads; it is derived from the body if omitted. to: an agent name, '*' for everyone, '#channel', or 'user'. reply_to joins that message's thread and marks a question answered. Returns id and thread_id: to wait for an answer, call message_wait(thread_id, after_id=id). sender: your agent type/name; pass it when you are a subagent, since subagents share one connection. Never include secrets; bodies are capped at 8,000 characters.")
|
|
464
|
+
def message_post(body: str, to: str = "*", subject: str | None = None, kind: str = "message", reply_to: int | None = None, sender: str | None = None, ctx: Context | None = None) -> dict:
|
|
465
|
+
if kind == "question" and to != "user" and not _MENTION.search(body):
|
|
466
|
+
raise ValueError("Ping rule: address the agent you are asking with @name, for example @Codex. Message not posted.")
|
|
467
|
+
def work():
|
|
468
|
+
_, project = _client()
|
|
469
|
+
declared = bool((sender or "").strip()) and sender.strip() != _client_name
|
|
470
|
+
with connection() as conn:
|
|
471
|
+
message_id = messages.post(conn, body, sender=_me(sender), sender_kind=_sender_kind(declared), to=to, subject=subject, kind=kind,
|
|
472
|
+
reply_to=reply_to, project=project, declared=declared)
|
|
473
|
+
return {"id": message_id, "thread_id": conn.execute("SELECT thread_id FROM messages WHERE id=?", (message_id,)).fetchone()[0]}
|
|
474
|
+
return _call("message_post", {"to": to, "kind": kind, "reply_to": reply_to, "sender": sender}, work, ctx)
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
@mcp.tool(description="Check messages for you: those addressed to you, to '*', or to channels you posted in. Call at the start of work and before finishing. With unread_only (the default) the returned messages are marked read for you; unread_only=False re-reads history without marking anything. reader: the same name you pass as sender (subagents share one connection).")
|
|
478
|
+
def message_inbox(unread_only: bool = True, limit: int = 20, reader: str | None = None, ctx: Context | None = None) -> list[dict]:
|
|
479
|
+
def work():
|
|
480
|
+
with connection() as conn:
|
|
481
|
+
return messages.inbox(conn, _me(reader), unread_only, limit)
|
|
482
|
+
return _call("message_inbox", {"unread_only": unread_only, "limit": limit, "reader": reader}, work, ctx)
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
@mcp.tool(description="Return every message of one thread, oldest first.")
|
|
486
|
+
def message_thread(thread_id: int, ctx: Context | None = None) -> list[dict]:
|
|
487
|
+
def work():
|
|
488
|
+
with connection() as conn:
|
|
489
|
+
return messages.thread(conn, thread_id)
|
|
490
|
+
return _call("message_thread", {"thread_id": thread_id}, work, ctx)
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
@mcp.tool(description="Wait for a reply in a thread instead of sleeping: returns messages newer than after_id as soon as one lands (checked every 2 s), or timed_out after timeout_s (max 60). Pass after_id = the id message_post returned, then the last_id of each result on the next call, so no reply is missed. Without after_id it starts after your own newest message in the thread (reader = your sender name), else after the thread's first message.")
|
|
494
|
+
async def message_wait(thread_id: int, timeout_s: int = 60, after_id: int | None = None, reader: str | None = None, ctx: Context | None = None) -> dict:
|
|
495
|
+
started = time.monotonic()
|
|
496
|
+
timeout = max(0, min(int(timeout_s), WAIT_MAX_S))
|
|
497
|
+
args = {"thread_id": thread_id, "timeout_s": timeout_s, "after_id": after_id, "reader": reader}
|
|
498
|
+
|
|
499
|
+
def read(after: int | None):
|
|
500
|
+
with connection() as conn:
|
|
501
|
+
if after is None:
|
|
502
|
+
if not conn.execute("SELECT 1 FROM messages WHERE thread_id=?", (thread_id,)).fetchone(): raise ValueError(f"thread {thread_id} does not exist")
|
|
503
|
+
# fix(2026-10-28): the baseline used to be the newest id at call time, so a reply that landed before the wait was never returned.
|
|
504
|
+
return messages.wait_baseline(conn, thread_id, _me(reader))
|
|
505
|
+
rows = messages.newer(conn, thread_id, after)
|
|
506
|
+
if rows: messages.mark_read(conn, [row["id"] for row in rows], _me(reader))
|
|
507
|
+
return rows
|
|
508
|
+
|
|
509
|
+
try:
|
|
510
|
+
_client(ctx)
|
|
511
|
+
after = await asyncio.to_thread(read, None) if after_id is None else int(after_id)
|
|
512
|
+
if after_id is not None: await asyncio.to_thread(read, None) # still reject an unknown thread
|
|
513
|
+
while True:
|
|
514
|
+
# Async with a thread hop: a blocking wait would stall every other tool call on this shared connection.
|
|
515
|
+
replies = await asyncio.to_thread(read, after)
|
|
516
|
+
if replies or time.monotonic() - started >= timeout:
|
|
517
|
+
result = {"thread_id": thread_id, "messages": replies, "timed_out": not replies, "last_id": max([after, *(m["id"] for m in replies)])}
|
|
518
|
+
break
|
|
519
|
+
await asyncio.sleep(min(WAIT_POLL_S, max(0.0, timeout - (time.monotonic() - started))))
|
|
520
|
+
except Exception as exc:
|
|
521
|
+
error = exc
|
|
522
|
+
def fail(): raise error
|
|
523
|
+
return _call("message_wait", args, fail, ctx, started)
|
|
524
|
+
return _call("message_wait", args, lambda: result, ctx, started)
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
@mcp.tool(description="Close a thread once it is resolved; the user can reopen it from the panel.")
|
|
528
|
+
def message_close(thread_id: int, ctx: Context | None = None) -> dict:
|
|
529
|
+
def work():
|
|
530
|
+
with connection() as conn:
|
|
531
|
+
return {"closed": messages.close(conn, thread_id)}
|
|
532
|
+
return _call("message_close", {"thread_id": thread_id}, work, ctx)
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def _shutdown() -> None:
|
|
536
|
+
if _client_id is not None:
|
|
537
|
+
with connection() as conn: conn.execute("UPDATE agent_clients SET ended_at=?,last_seen_at=? WHERE id=?", (now(),now(),_client_id))
|
|
538
|
+
|
|
539
|
+
atexit.register(_shutdown)
|
|
540
|
+
|
|
541
|
+
def run() -> None:
|
|
542
|
+
mcp.run(transport="stdio")
|