agent-mailbox 0.5.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.
@@ -0,0 +1,3 @@
1
+ """agent-mailbox: give every local AI agent its own mailbox."""
2
+
3
+ __version__ = "0.5.0"
@@ -0,0 +1,8 @@
1
+ """`python -m agent_mailbox` — delegates to the cleanup CLI (the mail root's
2
+ maintenance entry point; server startup is via `agent-mailbox` / `-m
3
+ agent_mailbox.server`)."""
4
+
5
+ from .cleanup import main
6
+
7
+ if __name__ == "__main__":
8
+ main()
@@ -0,0 +1,177 @@
1
+ """Scan a mail root for suspected test residue (dry-run by default).
2
+
3
+ ``python -m agent_mailbox.cleanup --dry-run`` lists — never deletes —
4
+ directories and letters that look like leftovers:
5
+
6
+ - agent ``inbox/``/``archive/`` directories whose id is not in registry.json;
7
+ - NEWBIE/WBTEST-style test-named directories;
8
+ - orphan letters: stray files directly under inbox/ or archive/, JSON a
9
+ mailbox_list can no longer parse, and ``*.tmp`` left by an interrupted
10
+ atomic write.
11
+
12
+ The default mode is dry-run. Actual deletion requires explicit ``--yes`` and
13
+ an interactive confirmation; registered-but-test-named directories are
14
+ reported as review-only and never deleted. Pure stdlib.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import argparse
20
+ import json
21
+ import os
22
+ import shutil
23
+ import sys
24
+ from pathlib import Path
25
+
26
+ # case-insensitive substrings that mark a directory as test residue
27
+ TEST_NAME_MARKERS = ("test", "newbie", "demo", "scratch")
28
+
29
+
30
+ def _is_test_name(name: str) -> bool:
31
+ low = name.lower()
32
+ return any(marker in low for marker in TEST_NAME_MARKERS)
33
+
34
+
35
+ def _human(n: int) -> str:
36
+ if n >= 1024 * 1024:
37
+ return f"{n / 1024 / 1024:.1f} MB"
38
+ if n >= 1024:
39
+ return f"{n / 1024:.1f} KB"
40
+ return f"{n} B"
41
+
42
+
43
+ def _dir_stats(d: Path) -> tuple[int, int]:
44
+ files = [p for p in d.rglob("*") if p.is_file()]
45
+ return sum(p.stat().st_size for p in files), len(files)
46
+
47
+
48
+ def scan(root: Path) -> list[dict]:
49
+ """Return suspected-residue findings for ``root`` (no mutation)."""
50
+ findings: list[dict] = []
51
+ agents: set[str] = set()
52
+ try:
53
+ reg = json.loads((root / "registry.json").read_text(encoding="utf-8"))
54
+ agents = set(reg.get("agents", {}))
55
+ except FileNotFoundError:
56
+ pass # empty/missing registry: every agent dir counts as residue
57
+ except json.JSONDecodeError as e:
58
+ raise SystemExit(f"cleanup: corrupt registry.json: {e}") from e
59
+
60
+ for base_name in ("inbox", "archive"):
61
+ base = root / base_name
62
+ if not base.is_dir():
63
+ continue
64
+ for entry in sorted(base.iterdir()):
65
+ rel = f"{base_name}/{entry.name}"
66
+ if entry.is_file():
67
+ findings.append({
68
+ "kind": "orphan-letter",
69
+ "path": rel,
70
+ "size": entry.stat().st_size,
71
+ "reason": "stray file directly under inbox/archive (no agent dir)",
72
+ "action": "delete file",
73
+ })
74
+ continue
75
+ size, n_files = _dir_stats(entry)
76
+ registered = entry.name in agents
77
+ test_named = _is_test_name(entry.name)
78
+ if not registered:
79
+ kind = "test-named-dir" if test_named else "unregistered-dir"
80
+ reason = "agent not in registry.json"
81
+ if test_named:
82
+ reason += "; name matches test pattern"
83
+ findings.append({
84
+ "kind": kind, "path": rel, "size": size,
85
+ "reason": reason, "action": f"delete dir ({n_files} files)",
86
+ })
87
+ elif test_named:
88
+ findings.append({
89
+ "kind": "test-named-dir", "path": rel, "size": size,
90
+ "reason": f"test-named but registered ({n_files} files)",
91
+ "action": "review only — not deleted",
92
+ })
93
+ else:
94
+ for p in sorted(entry.glob("*.json")):
95
+ try:
96
+ json.loads(p.read_text(encoding="utf-8"))
97
+ except (json.JSONDecodeError, OSError):
98
+ findings.append({
99
+ "kind": "orphan-letter",
100
+ "path": f"{rel}/{p.name}",
101
+ "size": p.stat().st_size,
102
+ "reason": "unparseable JSON — invisible to mailbox_list",
103
+ "action": "delete file",
104
+ })
105
+
106
+ for p in sorted(root.glob("*.tmp")):
107
+ findings.append({
108
+ "kind": "orphan-letter",
109
+ "path": p.name,
110
+ "size": p.stat().st_size,
111
+ "reason": "stray *.tmp from an interrupted atomic write",
112
+ "action": "delete file",
113
+ })
114
+ return findings
115
+
116
+
117
+ def _delete(root: Path, findings: list[dict]) -> int:
118
+ n = 0
119
+ for f in findings:
120
+ if not f["action"].startswith("delete"):
121
+ continue
122
+ path = root / f["path"]
123
+ if path.is_dir():
124
+ shutil.rmtree(path)
125
+ elif path.exists():
126
+ path.unlink()
127
+ n += 1
128
+ return n
129
+
130
+
131
+ def main(argv: list[str] | None = None) -> None:
132
+ parser = argparse.ArgumentParser(
133
+ prog="agent-mailbox-cleanup",
134
+ description="List (default) or delete suspected test residue in a mail root.",
135
+ )
136
+ parser.add_argument(
137
+ "--root", default=None,
138
+ help="mail root (default $AGENT_MAIL_HOME or ~/.agent-mail)",
139
+ )
140
+ parser.add_argument(
141
+ "--dry-run", action="store_true",
142
+ help="list suspects only, never delete (the default behaviour)",
143
+ )
144
+ parser.add_argument(
145
+ "--yes", action="store_true",
146
+ help="actually delete the listed suspects (asks for confirmation)",
147
+ )
148
+ args = parser.parse_args(argv)
149
+
150
+ root = Path(args.root or os.environ.get("AGENT_MAIL_HOME", "~/.agent-mail")).expanduser()
151
+ if not root.is_dir():
152
+ print(f"cleanup: mail root not found: {root}", file=sys.stderr)
153
+ raise SystemExit(1)
154
+
155
+ findings = scan(root)
156
+ if not findings:
157
+ print(f"cleanup: no suspected residue in {root}")
158
+ return
159
+
160
+ total = 0
161
+ for f in findings:
162
+ total += f["size"]
163
+ print(f"[{f['kind']}] {f['path']} {_human(f['size'])} ({f['reason']}) -> {f['action']}")
164
+ print(f"\n{len(findings)} finding(s), {_human(total)} total")
165
+
166
+ if args.dry_run or not args.yes:
167
+ print("nothing deleted (pass --yes to actually delete)")
168
+ return
169
+ answer = input(f"Delete the listed items in {root}? Type 'yes' to confirm: ")
170
+ if answer.strip().lower() != "yes":
171
+ print("aborted — nothing deleted")
172
+ return
173
+ print(f"deleted {_delete(root, findings)} item(s)")
174
+
175
+
176
+ if __name__ == "__main__":
177
+ main()
agent_mailbox/reap.py ADDED
@@ -0,0 +1,44 @@
1
+ """Reap orphaned ``acked`` mail back to ``pending`` (task t-6 wiring, v0.5).
2
+
3
+ ``MailStore.check()`` moves pending -> acked and hands the letters to the
4
+ caller; a caller that dies, is cancelled, or was a foreign-identity check
5
+ leaves them acked forever, invisible to a pending-only drain. This CLI is the
6
+ thin shell the wake loop calls *before* counting pending, so those letters
7
+ come back into view instead of hiding behind a zero count.
8
+
9
+ ``python -m agent_mailbox.reap --agent ZC --ttl 3600``
10
+ Never deletes anything: status flips back to pending and ``handled_log``
11
+ records a ``reclaimed`` entry. Pure stdlib.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import sys
18
+
19
+ from .store import MailboxError, MailStore
20
+
21
+
22
+ def main() -> None:
23
+ parser = argparse.ArgumentParser(prog="agent-mailbox-reap")
24
+ parser.add_argument("--agent", required=True, help="mailbox to reap (defaults to AGENT_MAIL_ID when omitted)")
25
+ parser.add_argument(
26
+ "--ttl",
27
+ type=float,
28
+ default=3600.0,
29
+ help="seconds a letter may stay acked before it is reclaimed (default: 3600)",
30
+ )
31
+ args = parser.parse_args()
32
+
33
+ try:
34
+ reaped = MailStore().reap_stale_acked(args.agent, ttl_seconds=args.ttl)
35
+ except MailboxError as e:
36
+ print(f"reap: {e}", file=sys.stderr)
37
+ raise SystemExit(1) from e
38
+ for mid in reaped:
39
+ print(f"reclaimed {mid}")
40
+ print(f"reap: {len(reaped)} reclaimed (agent={args.agent}, ttl={args.ttl:g}s)")
41
+
42
+
43
+ if __name__ == "__main__":
44
+ main()
@@ -0,0 +1,268 @@
1
+ """agent-mailbox MCP server.
2
+
3
+ Expose a local, file-backed mailbox as MCP tools. Any MCP-capable agent on
4
+ this machine can register once and then message every other agent — no cron,
5
+ no polling daemons, no shared markdown files.
6
+
7
+ Run: ``agent-mailbox`` (stdio transport, for host apps)
8
+ ``agent-mailbox --http 8642`` (streamable HTTP, for remote agents)
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import os
15
+ import sys
16
+ import time
17
+
18
+ from mcp.server.mcpserver import MCPServer
19
+
20
+ from .store import MailboxError, MailStore
21
+
22
+ server = MCPServer(
23
+ "agent-mailbox",
24
+ instructions=(
25
+ "A global mailbox for local AI agents. Register once with mailbox_register, "
26
+ "then use mailbox_send / mailbox_check / mailbox_reply / mailbox_list / "
27
+ "mailbox_done / mailbox_broadcast. Check your inbox when you start a session "
28
+ "and after finishing a task — messages wait here even when the recipient is offline. "
29
+ "Task cards: task_create / task_move / task_list manage a shared task board; "
30
+ "creating or moving a card auto-messages the assignee, so board motion wakes "
31
+ "agents without polling."
32
+ ),
33
+ )
34
+
35
+ _store: MailStore | None = None
36
+
37
+
38
+ def _store_instance() -> MailStore:
39
+ global _store
40
+ if _store is None:
41
+ _store = MailStore()
42
+ return _store
43
+
44
+
45
+ # --------------------------------------------------------------------- tools
46
+
47
+ @server.tool()
48
+ def mailbox_register(agent_id: str, owner: str = "", description: str = "") -> dict:
49
+ """Register this agent and claim its mailbox. Idempotent — safe to call again."""
50
+ return _store_instance().register(agent_id, owner, description)
51
+
52
+
53
+ @server.tool()
54
+ def mailbox_send(
55
+ to: str | list[str],
56
+ subject: str,
57
+ body: str,
58
+ priority: str = "normal",
59
+ reply_to: str | None = None,
60
+ from_id: str = "",
61
+ dedupe: bool = True,
62
+ ) -> dict:
63
+ """Send a message to one agent, a list of agents, or \"all\" for broadcast.
64
+
65
+ dedupe=True (default) suppresses a re-send of semantically identical
66
+ mail to a recipient whose inbox still holds it non-terminal (pending /
67
+ acked) within the 24h dedup window: that recipient's entry comes back as
68
+ {\"to\", \"deduped\": true, \"existing_id\"} with zero side effects — no
69
+ letter, no sent.log line, no webhook. Pass dedupe=False to exempt
70
+ periodic jobs. \"count\" counts only letters that actually landed.
71
+ """
72
+ frm = from_id or os.environ.get("AGENT_MAIL_ID", "")
73
+ if not frm:
74
+ raise MailboxError("from_id required (or set AGENT_MAIL_ID env)")
75
+ sent = _store_instance().send(
76
+ frm, to, subject, body, reply_to=reply_to, priority=priority, dedupe=dedupe
77
+ )
78
+ delivered = sum(1 for e in sent if not e.get("deduped"))
79
+ return {"delivered": sent, "count": delivered}
80
+
81
+
82
+ @server.tool()
83
+ def mailbox_check(agent_id: str = "", mark: bool = True) -> dict:
84
+ """Fetch your pending messages (they become acked). Call at session start."""
85
+ me = agent_id or os.environ.get("AGENT_MAIL_ID", "")
86
+ if not me:
87
+ raise MailboxError("agent_id required (or set AGENT_MAIL_ID env)")
88
+ msgs = _store_instance().check(me, mark=mark)
89
+ return {"agent_id": me, "unread": len(msgs), "messages": msgs}
90
+
91
+
92
+ @server.tool()
93
+ def mailbox_reply(msg_id: str, body: str, agent_id: str = "") -> dict:
94
+ """Reply to a message thread. Routes to the original sender automatically."""
95
+ me = agent_id or os.environ.get("AGENT_MAIL_ID", "")
96
+ if not me:
97
+ raise MailboxError("agent_id required (or set AGENT_MAIL_ID env)")
98
+ st = _store_instance()
99
+ mine = [m for m in st.list_messages(me) if m["id"] == msg_id]
100
+ from_archive = False
101
+ if not mine:
102
+ mine = [m for m in st.list_archived(me) if m["id"] == msg_id]
103
+ from_archive = True
104
+ if not mine:
105
+ raise MailboxError(
106
+ f"message {msg_id!r} not found in inbox or archive for {me!r}"
107
+ )
108
+ original = mine[0]
109
+ sent = st.send(
110
+ me,
111
+ original["from"],
112
+ f"Re: {original['subject']}",
113
+ body,
114
+ reply_to=msg_id,
115
+ dedupe=False, # replies are thread-addressed; keep the legacy contract
116
+ )
117
+ if from_archive:
118
+ # Original is already done + archived; nothing left to close.
119
+ return {"replied": sent[0], "closed": None, "archived_original": msg_id}
120
+ st.set_status(me, msg_id, "done")
121
+ return {"replied": sent[0], "closed": msg_id}
122
+
123
+
124
+ @server.tool()
125
+ def mailbox_list(agent_id: str = "", status: str | None = None) -> dict:
126
+ """List messages in your mailbox, optionally filtered by status."""
127
+ me = agent_id or os.environ.get("AGENT_MAIL_ID", "")
128
+ if not me:
129
+ raise MailboxError("agent_id required (or set AGENT_MAIL_ID env)")
130
+ msgs = _store_instance().list_messages(me, status)
131
+ return {"agent_id": me, "count": len(msgs), "messages": msgs}
132
+
133
+
134
+ @server.tool()
135
+ def mailbox_done(msg_id: str, agent_id: str = "") -> dict:
136
+ """Mark a message as handled. Done messages can be archived."""
137
+ me = agent_id or os.environ.get("AGENT_MAIL_ID", "")
138
+ if not me:
139
+ raise MailboxError("agent_id required (or set AGENT_MAIL_ID env)")
140
+ m = _store_instance().set_status(me, msg_id, "done")
141
+ n = _store_instance().archive_done(me)
142
+ return {"message": m["id"], "status": "done", "archived": n}
143
+
144
+
145
+ @server.tool()
146
+ def mailbox_broadcast(subject: str, body: str, from_id: str = "", dedupe: bool = True) -> dict:
147
+ """Broadcast to every registered agent (including boss). dedupe=True
148
+ (default) suppresses semantically identical re-broadcasts per recipient
149
+ within the dedup window — see mailbox_send."""
150
+ frm = from_id or os.environ.get("AGENT_MAIL_ID", "")
151
+ if not frm:
152
+ raise MailboxError("from_id required (or set AGENT_MAIL_ID env)")
153
+ sent = _store_instance().send(frm, "all", subject, body, priority="high", dedupe=dedupe)
154
+ delivered = sum(1 for e in sent if not e.get("deduped"))
155
+ return {"delivered": sent, "count": delivered}
156
+
157
+
158
+ @server.tool()
159
+ def mailbox_whoami() -> dict:
160
+ """List all registered agents and the mail root location."""
161
+ st = _store_instance()
162
+ reg = st.registry()
163
+ return {
164
+ "mail_root": str(st.root),
165
+ "default_identity": os.environ.get("AGENT_MAIL_ID", ""),
166
+ "agents": reg["agents"],
167
+ }
168
+
169
+
170
+ @server.tool()
171
+ def mailbox_wait(agent_id: str = "", timeout_seconds: float = 25.0) -> dict:
172
+ """Block until a new message arrives (long-poll, up to timeout). Returns
173
+ immediately if pending messages exist. Import 'time' is at module top."""
174
+ me = agent_id or os.environ.get("AGENT_MAIL_ID", "")
175
+ if not me:
176
+ raise MailboxError("agent_id required (or set AGENT_MAIL_ID env)")
177
+ st = _store_instance()
178
+ deadline = time.time() + max(1.0, min(timeout_seconds, 60.0))
179
+ while True:
180
+ msgs = st.list_messages(me, status="pending")
181
+ if msgs:
182
+ got = st.check(me, mark=True)
183
+ return {"agent_id": me, "received": len(got), "messages": got}
184
+ if time.time() >= deadline:
185
+ return {"agent_id": me, "received": 0, "messages": [], "timeout": True}
186
+ time.sleep(0.5)
187
+
188
+
189
+ # ---------------------------------------------------------------- task tools
190
+
191
+ @server.tool()
192
+ def task_create(
193
+ title: str, assignee: str, due: str = "", from_id: str = "", notify: bool = True
194
+ ) -> dict:
195
+ """Create a task card (starts at todo). The assignee is auto-messaged —
196
+ skip with notify=False."""
197
+ me = from_id or os.environ.get("AGENT_MAIL_ID", "")
198
+ if not me:
199
+ raise MailboxError("from_id required (or set AGENT_MAIL_ID env)")
200
+ task = _store_instance().task_create(title, assignee, me, due, notify=notify)
201
+ return {"task": task}
202
+
203
+
204
+ @server.tool()
205
+ def task_move(
206
+ task_id: str,
207
+ status: str,
208
+ assignee: str | None = None,
209
+ note: str = "",
210
+ force: bool = False,
211
+ notify: bool = True,
212
+ from_id: str = "",
213
+ ) -> dict:
214
+ """Move a task along todo→doing→review→done. Skips need force=True;
215
+ done is terminal. Pass assignee to reassign. The (new) assignee is
216
+ auto-messaged — moving a card wakes its owner."""
217
+ me = from_id or os.environ.get("AGENT_MAIL_ID", "")
218
+ if not me:
219
+ raise MailboxError("from_id required (or set AGENT_MAIL_ID env)")
220
+ task = _store_instance().task_move(
221
+ task_id, status, moved_by=me, assignee=assignee,
222
+ force=force, notify=notify, note=note,
223
+ )
224
+ return {"task": task}
225
+
226
+
227
+ @server.tool()
228
+ def task_list(assignee: str | None = None, status: str | None = None) -> dict:
229
+ """List task cards, optionally filtered by assignee and/or status."""
230
+ tasks = _store_instance().task_list(assignee=assignee, status=status)
231
+ return {"count": len(tasks), "tasks": tasks}
232
+
233
+
234
+ def main() -> None:
235
+ # MCP stdio speaks UTF-8; on Windows/macOS CI the default console codec
236
+ # (cp1252 etc.) cannot encode arrows/CJK in tool output and crashes the
237
+ # child process before the handshake completes.
238
+ for _stream in (sys.stdout, sys.stderr):
239
+ if _stream is not None and hasattr(_stream, "reconfigure"):
240
+ try:
241
+ _stream.reconfigure(encoding="utf-8")
242
+ except (OSError, ValueError):
243
+ pass
244
+
245
+ parser = argparse.ArgumentParser(prog="agent-mailbox")
246
+ parser.add_argument("--http", metavar="PORT", type=int, default=None,
247
+ help="serve streamable HTTP on PORT (default: stdio)")
248
+ parser.add_argument("--web", metavar="PORT", type=int, default=None,
249
+ help="serve the kanban board UI + JSON API on PORT (default: stdio)")
250
+ parser.add_argument("--home", metavar="DIR", default=None,
251
+ help="mail root directory (default: ~/.agent-mail)")
252
+ args = parser.parse_args()
253
+
254
+ if args.home:
255
+ os.environ["AGENT_MAIL_HOME"] = args.home
256
+
257
+ if args.web:
258
+ from .web import run_web
259
+
260
+ run_web(args.web)
261
+ elif args.http:
262
+ server.run(transport="streamable-http", port=args.http)
263
+ else:
264
+ server.run(transport="stdio")
265
+
266
+
267
+ if __name__ == "__main__":
268
+ main()