Skip to content

Commit d681b16

Browse files
committed
Add Agent Bridge addon
Agent Bridge embeds a control bridge in a running Gramps session so an AI agent can drive the live application: read and modify the tree, operate the UI, and create and load plugins on the fly. It ships an MCP (Model Context Protocol) server so any MCP-capable AI can drive Gramps through standard tools. The gramplet polls a watched control directory on the GTK main thread and executes submitted Python in a persistent namespace with dbstate, db, uistate, gui and gramps_lib bound; the MCP server is a thin stdio adapter over that directory. No network port is opened.
1 parent 771db58 commit d681b16

7 files changed

Lines changed: 898 additions & 0 deletions

File tree

AgentBridge/AgentBridge.gpr.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
#
2+
# Gramps - a GTK+/GNOME based genealogy program
3+
#
4+
# Copyright (C) 2026 Brian Caudill
5+
#
6+
# This program is free software; you can redistribute it and/or modify
7+
# it under the terms of the GNU General Public License as published by
8+
# the Free Software Foundation; either version 2 of the License, or
9+
# (at your option) any later version.
10+
#
11+
# This program is distributed in the hope that it will be useful,
12+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
13+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14+
# GNU General Public License for more details.
15+
#
16+
# You should have received a copy of the GNU General Public License along
17+
# with this program; if not, see <https://www.gnu.org/licenses/>.
18+
#
19+
20+
# ------------------------------------------------------------------------
21+
#
22+
# Register the Agent Bridge gramplet
23+
#
24+
# ------------------------------------------------------------------------
25+
register(
26+
GRAMPLET,
27+
id="Agent Bridge",
28+
name=_("Agent Bridge"),
29+
description=_(
30+
"Embeds a control bridge in Gramps so an AI agent can drive the live "
31+
"application through a watched directory or an MCP server. Executes "
32+
"submitted Python on the GTK main thread. For developers and power "
33+
"users; runs arbitrary code at your privileges."
34+
),
35+
version="0.0.1",
36+
gramps_target_version="6.0",
37+
status=STABLE,
38+
audience=DEVELOPER,
39+
fname="AgentBridge.py",
40+
gramplet="AgentBridge",
41+
gramplet_title=_("Agent Bridge"),
42+
height=140,
43+
expand=True,
44+
detached_width=520,
45+
detached_height=300,
46+
navtypes=["Dashboard"],
47+
authors=["Brian Caudill"],
48+
authors_email=["brian.m.caudill@gmail.com"],
49+
maintainers=["Brian Caudill"],
50+
maintainers_email=["brian.m.caudill@gmail.com"],
51+
help_url="Addon:AgentBridge",
52+
)

AgentBridge/AgentBridge.py

Lines changed: 305 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,305 @@
1+
#
2+
# Gramps - a GTK+/GNOME based genealogy program
3+
#
4+
# Copyright (C) 2026 Brian Caudill
5+
#
6+
# This program is free software; you can redistribute it and/or modify
7+
# it under the terms of the GNU General Public License as published by
8+
# the Free Software Foundation; either version 2 of the License, or
9+
# (at your option) any later version.
10+
#
11+
# This program is distributed in the hope that it will be useful,
12+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
13+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14+
# GNU General Public License for more details.
15+
#
16+
# You should have received a copy of the GNU General Public License along
17+
# with this program; if not, see <https://www.gnu.org/licenses/>.
18+
#
19+
20+
"""
21+
Agent Bridge gramplet.
22+
23+
Embeds a control bridge inside the running Gramps process so an external agent
24+
(typically an AI via the bundled MCP server) can drive the live application.
25+
The agent communicates through a watched control directory: it drops request
26+
files, the bridge executes them on the GTK main thread and writes response
27+
files.
28+
29+
SECURITY: this executes arbitrary Python inside Gramps with the privileges of
30+
the user running it. It listens on no network port -- control is purely via
31+
files under ``~/.gramps_agent`` -- so anyone able to write to that directory
32+
(i.e. this user account) can run code in Gramps. Only enable it on a machine
33+
you trust, and remove the gramplet when you are done.
34+
"""
35+
36+
# -------------------------------------------------------------------------
37+
#
38+
# Standard Python modules
39+
#
40+
# -------------------------------------------------------------------------
41+
import os
42+
import io
43+
import json
44+
import time
45+
import logging
46+
import traceback
47+
import contextlib
48+
49+
# -------------------------------------------------------------------------
50+
#
51+
# GTK/Gnome modules
52+
#
53+
# -------------------------------------------------------------------------
54+
from gi.repository import GLib
55+
56+
# -------------------------------------------------------------------------
57+
#
58+
# Gramps modules
59+
#
60+
# -------------------------------------------------------------------------
61+
from gramps.gen.plug import Gramplet
62+
from gramps.gen.const import GRAMPS_LOCALE as glocale
63+
64+
try:
65+
_trans = glocale.get_addon_translator(__file__)
66+
except ValueError:
67+
_trans = glocale.translation
68+
_ = _trans.gettext
69+
70+
LOG = logging.getLogger("AgentBridge")
71+
72+
# -------------------------------------------------------------------------
73+
#
74+
# Constants
75+
#
76+
# -------------------------------------------------------------------------
77+
CONTROL_DIR = os.path.join(os.path.expanduser("~"), ".gramps_agent")
78+
REQ_DIR = os.path.join(CONTROL_DIR, "requests")
79+
RESP_DIR = os.path.join(CONTROL_DIR, "responses")
80+
POLL_MS = 300
81+
MAX_REPR = 20000
82+
83+
84+
# -------------------------------------------------------------------------
85+
#
86+
# Helper functions
87+
#
88+
# -------------------------------------------------------------------------
89+
def _safe_repr(value):
90+
"""
91+
Return a length-bounded repr of a value, never raising.
92+
"""
93+
try:
94+
text = repr(value)
95+
except Exception:
96+
try:
97+
text = "<unreprable %s>" % type(value).__name__
98+
except Exception:
99+
text = "<unreprable>"
100+
if len(text) > MAX_REPR:
101+
text = text[:MAX_REPR] + "... [truncated]"
102+
return text
103+
104+
105+
# -------------------------------------------------------------------------
106+
#
107+
# AgentBridge
108+
#
109+
# -------------------------------------------------------------------------
110+
class AgentBridge(Gramplet):
111+
"""
112+
Poll a control directory and execute agent requests on the main loop.
113+
"""
114+
115+
def init(self):
116+
"""
117+
Set up the control directory and start the polling timer.
118+
"""
119+
self._ns = None
120+
self._timer_id = None
121+
self._served = 0
122+
self.gui.set_text(_("Agent Bridge starting..."))
123+
try:
124+
os.makedirs(REQ_DIR, exist_ok=True)
125+
os.makedirs(RESP_DIR, exist_ok=True)
126+
except OSError as err:
127+
self.gui.set_text(_("Agent Bridge error: %s") % err)
128+
LOG.error("Could not create control dir: %s", err)
129+
return
130+
self._start()
131+
self._set_status()
132+
133+
def _start(self):
134+
"""
135+
Start the GLib poll timer if it is not already running.
136+
"""
137+
if self._timer_id is None:
138+
self._timer_id = GLib.timeout_add(POLL_MS, self._poll)
139+
140+
def _set_status(self):
141+
"""
142+
Update the gramplet status text.
143+
"""
144+
db_name = _("(no tree)")
145+
try:
146+
if self.dbstate.is_open():
147+
db_name = self.dbstate.db.get_dbname()
148+
except Exception:
149+
pass
150+
self.gui.set_text(
151+
_("Agent Bridge active.\n")
152+
+ _("Watching: %s\n") % REQ_DIR
153+
+ _("Tree: %s\n") % db_name
154+
+ _("Requests served: %d") % self._served
155+
)
156+
157+
def main(self):
158+
"""
159+
Refresh status on update events. No periodic work is done here.
160+
"""
161+
self._set_status()
162+
163+
def _namespace(self):
164+
"""
165+
Return the persistent exec namespace, refreshing live references.
166+
"""
167+
if self._ns is None:
168+
import gramps.gen.lib as gramps_lib
169+
170+
self._ns = {
171+
"__name__": "agent_bridge",
172+
"gramps_lib": gramps_lib,
173+
"bridge": self,
174+
}
175+
# Keep live handles fresh on every call -- the tree can change.
176+
self._ns["dbstate"] = self.dbstate
177+
self._ns["uistate"] = self.uistate
178+
self._ns["gui"] = self.gui
179+
self._ns["db"] = self.dbstate.db if self.dbstate.is_open() else None
180+
return self._ns
181+
182+
def _poll(self):
183+
"""
184+
Process any pending request files. Always returns True to keep polling.
185+
"""
186+
try:
187+
names = sorted(
188+
name for name in os.listdir(REQ_DIR) if name.endswith(".req.json")
189+
)
190+
except FileNotFoundError:
191+
return True
192+
for name in names:
193+
path = os.path.join(REQ_DIR, name)
194+
try:
195+
with open(path, "r", encoding="utf-8") as handle:
196+
req = json.load(handle)
197+
except (ValueError, OSError):
198+
# File may still be partly written; leave it for the next tick.
199+
continue
200+
try:
201+
os.remove(path)
202+
except OSError:
203+
pass
204+
rid = req.get("id") or name[: -len(".req.json")]
205+
resp = self._handle(req)
206+
self._served += 1
207+
self._write_response(rid, resp)
208+
self._set_status()
209+
return True
210+
211+
def _handle(self, req):
212+
"""
213+
Dispatch a single request to the matching action handler.
214+
"""
215+
action = req.get("action", "eval")
216+
try:
217+
if action == "ping":
218+
return {"ok": True, "pong": True, "served": self._served}
219+
if action == "eval":
220+
return self._do_eval(req.get("code", ""))
221+
if action == "install_plugin":
222+
return self._do_install(req)
223+
return {"ok": False, "error": "unknown action: %r" % action}
224+
except Exception:
225+
return {"ok": False, "error": traceback.format_exc()}
226+
227+
def _do_eval(self, code):
228+
"""
229+
Execute submitted code in the persistent namespace.
230+
231+
Captures stdout/stderr. If the code assigns a variable named ``result``
232+
its repr is returned.
233+
"""
234+
namespace = self._namespace()
235+
namespace["result"] = None
236+
stream = io.StringIO()
237+
try:
238+
compiled = compile(code, "<agent>", "exec")
239+
with contextlib.redirect_stdout(stream), contextlib.redirect_stderr(
240+
stream
241+
):
242+
exec(compiled, namespace)
243+
return {
244+
"ok": True,
245+
"stdout": stream.getvalue(),
246+
"result": _safe_repr(namespace.get("result")),
247+
}
248+
except Exception:
249+
return {
250+
"ok": False,
251+
"stdout": stream.getvalue(),
252+
"error": traceback.format_exc(),
253+
}
254+
255+
def _do_install(self, req):
256+
"""
257+
Write one or more plugin files to the user plugin directory and reload.
258+
259+
Expects ``req['files']`` as a mapping of relative path -> file contents,
260+
and an optional ``req['name']`` used as the containing directory.
261+
"""
262+
from gramps.gen.const import USER_PLUGINS
263+
from gramps.gen.plug import BasePluginManager
264+
265+
files = req.get("files") or {}
266+
if not isinstance(files, dict) or not files:
267+
return {"ok": False, "error": "install_plugin needs a 'files' mapping"}
268+
subdir = req.get("name", "agent_plugin")
269+
target = os.path.join(USER_PLUGINS, subdir)
270+
os.makedirs(target, exist_ok=True)
271+
written = []
272+
for relpath, content in files.items():
273+
dest = os.path.join(target, relpath)
274+
os.makedirs(os.path.dirname(dest) or target, exist_ok=True)
275+
with open(dest, "w", encoding="utf-8") as handle:
276+
handle.write(content)
277+
written.append(dest)
278+
pmgr = BasePluginManager.get_instance()
279+
# Scan the freshly written directory so a brand-new plugin registers.
280+
pmgr.reg_plugins(target, self.dbstate, self.uistate, rescan=True)
281+
return {"ok": True, "written": written, "dir": target}
282+
283+
def _write_response(self, rid, resp):
284+
"""
285+
Write a response file atomically (temp file then rename).
286+
"""
287+
resp.setdefault("ok", True)
288+
resp["id"] = rid
289+
resp["ts"] = time.time()
290+
tmp = os.path.join(RESP_DIR, "%s.part" % rid)
291+
final = os.path.join(RESP_DIR, "%s.resp.json" % rid)
292+
try:
293+
with open(tmp, "w", encoding="utf-8") as handle:
294+
json.dump(resp, handle)
295+
os.replace(tmp, final)
296+
except OSError as err:
297+
LOG.error("Could not write response %s: %s", rid, err)
298+
299+
def on_save(self):
300+
"""
301+
Stop the poll timer when the gramplet is disposed.
302+
"""
303+
if self._timer_id is not None:
304+
GLib.source_remove(self._timer_id)
305+
self._timer_id = None

AgentBridge/MANIFEST

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
AgentBridge/README.md

0 commit comments

Comments
 (0)