opencode-swarm-plugin 0.2.0 → 0.4.0

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.
@@ -1,879 +0,0 @@
1
- """
2
- Minimal Agent Mail Server for Integration Testing
3
-
4
- A lightweight MCP-compatible server that provides multi-agent coordination
5
- capabilities: messaging, file reservations, and project management.
6
-
7
- This is NOT the production Agent Mail server - it's a minimal implementation
8
- for testing the opencode-swarm-plugin MCP client.
9
- """
10
-
11
- import random
12
- import sqlite3
13
- import uuid
14
- from contextlib import contextmanager
15
- from datetime import datetime, timedelta, timezone
16
- from pathlib import Path
17
- from typing import Any
18
-
19
- from fastapi import FastAPI, HTTPException
20
- from fastapi.responses import JSONResponse
21
- from pydantic import BaseModel
22
-
23
- # =============================================================================
24
- # Configuration
25
- # =============================================================================
26
-
27
- DB_PATH = Path("/data/agentmail.db")
28
-
29
- # Agent name generation wordlists
30
- ADJECTIVES = [
31
- "Blue", "Red", "Green", "Golden", "Silver", "Crystal", "Shadow", "Bright",
32
- "Swift", "Silent", "Bold", "Calm", "Wild", "Noble", "Frost", "Storm",
33
- "Dawn", "Dusk", "Iron", "Copper", "Azure", "Crimson", "Amber", "Jade",
34
- "Coral", "Misty", "Sunny", "Lunar", "Solar", "Cosmic", "Terra", "Aqua",
35
- ]
36
-
37
- NOUNS = [
38
- "Lake", "Stone", "River", "Mountain", "Forest", "Valley", "Meadow", "Peak",
39
- "Canyon", "Desert", "Ocean", "Island", "Prairie", "Grove", "Creek", "Ridge",
40
- "Harbor", "Cliff", "Glacier", "Dune", "Marsh", "Brook", "Hill", "Plain",
41
- "Bay", "Cape", "Delta", "Fjord", "Mesa", "Plateau", "Reef", "Tundra",
42
- ]
43
-
44
- # =============================================================================
45
- # Database Setup
46
- # =============================================================================
47
-
48
- def init_db():
49
- """Initialize SQLite database with required tables."""
50
- DB_PATH.parent.mkdir(parents=True, exist_ok=True)
51
-
52
- conn = sqlite3.connect(str(DB_PATH))
53
- conn.row_factory = sqlite3.Row
54
-
55
- conn.executescript("""
56
- -- Projects table
57
- CREATE TABLE IF NOT EXISTS projects (
58
- id INTEGER PRIMARY KEY AUTOINCREMENT,
59
- slug TEXT UNIQUE NOT NULL,
60
- human_key TEXT NOT NULL,
61
- created_at TEXT NOT NULL DEFAULT (datetime('now'))
62
- );
63
-
64
- -- Agents table
65
- CREATE TABLE IF NOT EXISTS agents (
66
- id INTEGER PRIMARY KEY AUTOINCREMENT,
67
- name TEXT NOT NULL,
68
- program TEXT NOT NULL,
69
- model TEXT NOT NULL,
70
- task_description TEXT,
71
- inception_ts TEXT NOT NULL DEFAULT (datetime('now')),
72
- last_active_ts TEXT NOT NULL DEFAULT (datetime('now')),
73
- project_id INTEGER NOT NULL,
74
- FOREIGN KEY (project_id) REFERENCES projects(id),
75
- UNIQUE (name, project_id)
76
- );
77
-
78
- -- Messages table
79
- CREATE TABLE IF NOT EXISTS messages (
80
- id INTEGER PRIMARY KEY AUTOINCREMENT,
81
- project_id INTEGER NOT NULL,
82
- sender_id INTEGER NOT NULL,
83
- subject TEXT NOT NULL,
84
- body_md TEXT,
85
- thread_id TEXT,
86
- importance TEXT DEFAULT 'normal',
87
- ack_required INTEGER DEFAULT 0,
88
- kind TEXT DEFAULT 'message',
89
- created_ts TEXT NOT NULL DEFAULT (datetime('now')),
90
- FOREIGN KEY (project_id) REFERENCES projects(id),
91
- FOREIGN KEY (sender_id) REFERENCES agents(id)
92
- );
93
-
94
- -- Message recipients table (many-to-many)
95
- CREATE TABLE IF NOT EXISTS message_recipients (
96
- message_id INTEGER NOT NULL,
97
- agent_id INTEGER NOT NULL,
98
- read_at TEXT,
99
- acked_at TEXT,
100
- PRIMARY KEY (message_id, agent_id),
101
- FOREIGN KEY (message_id) REFERENCES messages(id),
102
- FOREIGN KEY (agent_id) REFERENCES agents(id)
103
- );
104
-
105
- -- File reservations table
106
- CREATE TABLE IF NOT EXISTS file_reservations (
107
- id INTEGER PRIMARY KEY AUTOINCREMENT,
108
- project_id INTEGER NOT NULL,
109
- agent_id INTEGER NOT NULL,
110
- path_pattern TEXT NOT NULL,
111
- exclusive INTEGER DEFAULT 1,
112
- reason TEXT,
113
- created_ts TEXT NOT NULL DEFAULT (datetime('now')),
114
- expires_ts TEXT NOT NULL,
115
- FOREIGN KEY (project_id) REFERENCES projects(id),
116
- FOREIGN KEY (agent_id) REFERENCES agents(id)
117
- );
118
-
119
- -- Full-text search for messages
120
- CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
121
- subject, body_md, content='messages', content_rowid='id'
122
- );
123
-
124
- -- Triggers to keep FTS in sync
125
- CREATE TRIGGER IF NOT EXISTS messages_ai AFTER INSERT ON messages BEGIN
126
- INSERT INTO messages_fts(rowid, subject, body_md)
127
- VALUES (new.id, new.subject, new.body_md);
128
- END;
129
-
130
- CREATE TRIGGER IF NOT EXISTS messages_ad AFTER DELETE ON messages BEGIN
131
- INSERT INTO messages_fts(messages_fts, rowid, subject, body_md)
132
- VALUES ('delete', old.id, old.subject, old.body_md);
133
- END;
134
-
135
- CREATE TRIGGER IF NOT EXISTS messages_au AFTER UPDATE ON messages BEGIN
136
- INSERT INTO messages_fts(messages_fts, rowid, subject, body_md)
137
- VALUES ('delete', old.id, old.subject, old.body_md);
138
- INSERT INTO messages_fts(rowid, subject, body_md)
139
- VALUES (new.id, new.subject, new.body_md);
140
- END;
141
- """)
142
-
143
- conn.commit()
144
- conn.close()
145
-
146
-
147
- @contextmanager
148
- def get_db():
149
- """Get database connection with row factory."""
150
- conn = sqlite3.connect(str(DB_PATH))
151
- conn.row_factory = sqlite3.Row
152
- try:
153
- yield conn
154
- conn.commit()
155
- finally:
156
- conn.close()
157
-
158
-
159
- def generate_agent_name() -> str:
160
- """Generate a random adjective+noun agent name."""
161
- return f"{random.choice(ADJECTIVES)}{random.choice(NOUNS)}"
162
-
163
-
164
- def generate_slug(human_key: str) -> str:
165
- """Generate a URL-safe slug from a human key."""
166
- # Simple slug: replace path separators and special chars
167
- slug = human_key.replace("/", "_").replace("\\", "_").replace(" ", "_")
168
- slug = "".join(c for c in slug if c.isalnum() or c == "_")
169
- return slug.lower()[:64]
170
-
171
-
172
- def now_iso() -> str:
173
- """Get current time in ISO format."""
174
- return datetime.now(timezone.utc).isoformat()
175
-
176
-
177
- # =============================================================================
178
- # FastAPI App
179
- # =============================================================================
180
-
181
- app = FastAPI(title="Agent Mail Test Server", version="0.1.0")
182
-
183
-
184
- @app.on_event("startup")
185
- async def startup():
186
- """Initialize database on startup."""
187
- init_db()
188
-
189
-
190
- # =============================================================================
191
- # Health Endpoints
192
- # =============================================================================
193
-
194
- @app.get("/health/liveness")
195
- async def health_liveness():
196
- """Liveness check for container health."""
197
- return {"status": "ok", "timestamp": now_iso()}
198
-
199
-
200
- @app.get("/health/readiness")
201
- async def health_readiness():
202
- """Readiness check - verify database is accessible."""
203
- try:
204
- with get_db() as conn:
205
- conn.execute("SELECT 1")
206
- return {"status": "ready", "timestamp": now_iso()}
207
- except Exception as e:
208
- raise HTTPException(status_code=503, detail=str(e))
209
-
210
-
211
- # =============================================================================
212
- # MCP JSON-RPC Endpoint
213
- # =============================================================================
214
-
215
- class MCPRequest(BaseModel):
216
- """MCP JSON-RPC request format."""
217
- jsonrpc: str = "2.0"
218
- id: str
219
- method: str
220
- params: dict[str, Any] = {}
221
-
222
-
223
- class MCPError(BaseModel):
224
- """MCP JSON-RPC error format."""
225
- code: int
226
- message: str
227
- data: Any = None
228
-
229
-
230
- class MCPResponse(BaseModel):
231
- """MCP JSON-RPC response format."""
232
- jsonrpc: str = "2.0"
233
- id: str
234
- result: Any = None
235
- error: MCPError | None = None
236
-
237
-
238
- @app.post("/mcp/")
239
- async def mcp_endpoint(request: MCPRequest):
240
- """
241
- MCP JSON-RPC endpoint.
242
-
243
- Handles tools/call method for Agent Mail operations.
244
- """
245
- if request.method != "tools/call":
246
- return MCPResponse(
247
- id=request.id,
248
- error=MCPError(
249
- code=-32601,
250
- message=f"Method not found: {request.method}",
251
- )
252
- )
253
-
254
- tool_name = request.params.get("name", "")
255
- arguments = request.params.get("arguments", {})
256
-
257
- try:
258
- result = await dispatch_tool(tool_name, arguments)
259
- return MCPResponse(id=request.id, result=result)
260
- except ValueError as e:
261
- return MCPResponse(
262
- id=request.id,
263
- error=MCPError(code=-32602, message=str(e))
264
- )
265
- except Exception as e:
266
- return MCPResponse(
267
- id=request.id,
268
- error=MCPError(code=-32000, message=str(e))
269
- )
270
-
271
-
272
- # =============================================================================
273
- # Tool Dispatcher
274
- # =============================================================================
275
-
276
- async def dispatch_tool(name: str, args: dict[str, Any]) -> Any:
277
- """Dispatch tool call to appropriate handler."""
278
- tools = {
279
- "ensure_project": tool_ensure_project,
280
- "register_agent": tool_register_agent,
281
- "send_message": tool_send_message,
282
- "fetch_inbox": tool_fetch_inbox,
283
- "mark_message_read": tool_mark_message_read,
284
- "summarize_thread": tool_summarize_thread,
285
- "file_reservation_paths": tool_file_reservation_paths,
286
- "release_file_reservations": tool_release_file_reservations,
287
- "acknowledge_message": tool_acknowledge_message,
288
- "search_messages": tool_search_messages,
289
- }
290
-
291
- handler = tools.get(name)
292
- if not handler:
293
- raise ValueError(f"Unknown tool: {name}")
294
-
295
- return await handler(args)
296
-
297
-
298
- # =============================================================================
299
- # Tool Implementations
300
- # =============================================================================
301
-
302
- async def tool_ensure_project(args: dict[str, Any]) -> dict:
303
- """Create or get a project by human_key."""
304
- human_key = args.get("human_key")
305
- if not human_key:
306
- raise ValueError("human_key is required")
307
-
308
- slug = generate_slug(human_key)
309
-
310
- with get_db() as conn:
311
- # Try to find existing project
312
- row = conn.execute(
313
- "SELECT * FROM projects WHERE human_key = ?",
314
- (human_key,)
315
- ).fetchone()
316
-
317
- if row:
318
- return dict(row)
319
-
320
- # Create new project
321
- cursor = conn.execute(
322
- "INSERT INTO projects (slug, human_key, created_at) VALUES (?, ?, ?)",
323
- (slug, human_key, now_iso())
324
- )
325
- project_id = cursor.lastrowid
326
-
327
- row = conn.execute(
328
- "SELECT * FROM projects WHERE id = ?",
329
- (project_id,)
330
- ).fetchone()
331
-
332
- return dict(row)
333
-
334
-
335
- async def tool_register_agent(args: dict[str, Any]) -> dict:
336
- """Register an agent with a project."""
337
- project_key = args.get("project_key")
338
- program = args.get("program", "unknown")
339
- model = args.get("model", "unknown")
340
- name = args.get("name")
341
- task_description = args.get("task_description", "")
342
-
343
- if not project_key:
344
- raise ValueError("project_key is required")
345
-
346
- with get_db() as conn:
347
- # Get project
348
- project = conn.execute(
349
- "SELECT * FROM projects WHERE human_key = ?",
350
- (project_key,)
351
- ).fetchone()
352
-
353
- if not project:
354
- raise ValueError(f"Project not found: {project_key}")
355
-
356
- project_id = project["id"]
357
-
358
- # Generate name if not provided
359
- if not name:
360
- # Keep trying until we get a unique name
361
- for _ in range(100):
362
- name = generate_agent_name()
363
- existing = conn.execute(
364
- "SELECT id FROM agents WHERE name = ? AND project_id = ?",
365
- (name, project_id)
366
- ).fetchone()
367
- if not existing:
368
- break
369
- else:
370
- name = f"{generate_agent_name()}_{uuid.uuid4().hex[:4]}"
371
-
372
- # Check if agent already exists
373
- existing = conn.execute(
374
- "SELECT * FROM agents WHERE name = ? AND project_id = ?",
375
- (name, project_id)
376
- ).fetchone()
377
-
378
- if existing:
379
- # Update last_active_ts
380
- conn.execute(
381
- "UPDATE agents SET last_active_ts = ? WHERE id = ?",
382
- (now_iso(), existing["id"])
383
- )
384
- return dict(existing)
385
-
386
- # Create new agent
387
- now = now_iso()
388
- cursor = conn.execute(
389
- """INSERT INTO agents
390
- (name, program, model, task_description, inception_ts, last_active_ts, project_id)
391
- VALUES (?, ?, ?, ?, ?, ?, ?)""",
392
- (name, program, model, task_description, now, now, project_id)
393
- )
394
- agent_id = cursor.lastrowid
395
-
396
- row = conn.execute(
397
- "SELECT * FROM agents WHERE id = ?",
398
- (agent_id,)
399
- ).fetchone()
400
-
401
- return dict(row)
402
-
403
-
404
- async def tool_send_message(args: dict[str, Any]) -> dict:
405
- """Send a message to other agents."""
406
- project_key = args.get("project_key")
407
- sender_name = args.get("sender_name")
408
- to = args.get("to", [])
409
- subject = args.get("subject", "")
410
- body_md = args.get("body_md", "")
411
- thread_id = args.get("thread_id")
412
- importance = args.get("importance", "normal")
413
- ack_required = args.get("ack_required", False)
414
-
415
- if not project_key:
416
- raise ValueError("project_key is required")
417
- if not sender_name:
418
- raise ValueError("sender_name is required")
419
- if not to:
420
- raise ValueError("to is required (list of recipient names)")
421
-
422
- with get_db() as conn:
423
- # Get project
424
- project = conn.execute(
425
- "SELECT * FROM projects WHERE human_key = ?",
426
- (project_key,)
427
- ).fetchone()
428
- if not project:
429
- raise ValueError(f"Project not found: {project_key}")
430
-
431
- project_id = project["id"]
432
-
433
- # Get sender agent
434
- sender = conn.execute(
435
- "SELECT * FROM agents WHERE name = ? AND project_id = ?",
436
- (sender_name, project_id)
437
- ).fetchone()
438
- if not sender:
439
- raise ValueError(f"Sender agent not found: {sender_name}")
440
-
441
- # Create message
442
- cursor = conn.execute(
443
- """INSERT INTO messages
444
- (project_id, sender_id, subject, body_md, thread_id, importance, ack_required, created_ts)
445
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
446
- (project_id, sender["id"], subject, body_md, thread_id, importance,
447
- 1 if ack_required else 0, now_iso())
448
- )
449
- message_id = cursor.lastrowid
450
-
451
- # Add recipients
452
- for recipient_name in to:
453
- recipient = conn.execute(
454
- "SELECT * FROM agents WHERE name = ? AND project_id = ?",
455
- (recipient_name, project_id)
456
- ).fetchone()
457
-
458
- if recipient:
459
- conn.execute(
460
- "INSERT INTO message_recipients (message_id, agent_id) VALUES (?, ?)",
461
- (message_id, recipient["id"])
462
- )
463
-
464
- return {
465
- "id": message_id,
466
- "subject": subject,
467
- "sent_to": to,
468
- "created_ts": now_iso(),
469
- }
470
-
471
-
472
- async def tool_fetch_inbox(args: dict[str, Any]) -> list[dict]:
473
- """Fetch inbox messages for an agent."""
474
- project_key = args.get("project_key")
475
- agent_name = args.get("agent_name")
476
- limit = args.get("limit", 10)
477
- include_bodies = args.get("include_bodies", False)
478
- urgent_only = args.get("urgent_only", False)
479
- since_ts = args.get("since_ts")
480
-
481
- if not project_key:
482
- raise ValueError("project_key is required")
483
- if not agent_name:
484
- raise ValueError("agent_name is required")
485
-
486
- with get_db() as conn:
487
- # Get project and agent
488
- project = conn.execute(
489
- "SELECT * FROM projects WHERE human_key = ?",
490
- (project_key,)
491
- ).fetchone()
492
- if not project:
493
- raise ValueError(f"Project not found: {project_key}")
494
-
495
- agent = conn.execute(
496
- "SELECT * FROM agents WHERE name = ? AND project_id = ?",
497
- (agent_name, project["id"])
498
- ).fetchone()
499
- if not agent:
500
- raise ValueError(f"Agent not found: {agent_name}")
501
-
502
- # Build query
503
- query = """
504
- SELECT m.*, a.name as from_name
505
- FROM messages m
506
- JOIN message_recipients mr ON m.id = mr.message_id
507
- JOIN agents a ON m.sender_id = a.id
508
- WHERE mr.agent_id = ?
509
- """
510
- params: list[Any] = [agent["id"]]
511
-
512
- if urgent_only:
513
- query += " AND m.importance = 'urgent'"
514
-
515
- if since_ts:
516
- query += " AND m.created_ts > ?"
517
- params.append(since_ts)
518
-
519
- query += " ORDER BY m.created_ts DESC LIMIT ?"
520
- params.append(limit)
521
-
522
- rows = conn.execute(query, params).fetchall()
523
-
524
- messages = []
525
- for row in rows:
526
- msg = {
527
- "id": row["id"],
528
- "subject": row["subject"],
529
- "from": row["from_name"],
530
- "created_ts": row["created_ts"],
531
- "importance": row["importance"],
532
- "ack_required": bool(row["ack_required"]),
533
- "thread_id": row["thread_id"],
534
- "kind": row["kind"],
535
- }
536
- if include_bodies:
537
- msg["body_md"] = row["body_md"]
538
- messages.append(msg)
539
-
540
- return messages
541
-
542
-
543
- async def tool_mark_message_read(args: dict[str, Any]) -> dict:
544
- """Mark a message as read."""
545
- project_key = args.get("project_key")
546
- agent_name = args.get("agent_name")
547
- message_id = args.get("message_id")
548
-
549
- if not all([project_key, agent_name, message_id]):
550
- raise ValueError("project_key, agent_name, and message_id are required")
551
-
552
- with get_db() as conn:
553
- # Get agent
554
- project = conn.execute(
555
- "SELECT * FROM projects WHERE human_key = ?",
556
- (project_key,)
557
- ).fetchone()
558
- if not project:
559
- raise ValueError(f"Project not found: {project_key}")
560
-
561
- agent = conn.execute(
562
- "SELECT * FROM agents WHERE name = ? AND project_id = ?",
563
- (agent_name, project["id"])
564
- ).fetchone()
565
- if not agent:
566
- raise ValueError(f"Agent not found: {agent_name}")
567
-
568
- # Update read timestamp
569
- conn.execute(
570
- """UPDATE message_recipients
571
- SET read_at = ?
572
- WHERE message_id = ? AND agent_id = ?""",
573
- (now_iso(), message_id, agent["id"])
574
- )
575
-
576
- return {"message_id": message_id, "read_at": now_iso()}
577
-
578
-
579
- async def tool_summarize_thread(args: dict[str, Any]) -> dict:
580
- """Summarize a message thread."""
581
- project_key = args.get("project_key")
582
- thread_id = args.get("thread_id")
583
- include_examples = args.get("include_examples", False)
584
-
585
- if not project_key:
586
- raise ValueError("project_key is required")
587
- if not thread_id:
588
- raise ValueError("thread_id is required")
589
-
590
- with get_db() as conn:
591
- # Get project
592
- project = conn.execute(
593
- "SELECT * FROM projects WHERE human_key = ?",
594
- (project_key,)
595
- ).fetchone()
596
- if not project:
597
- raise ValueError(f"Project not found: {project_key}")
598
-
599
- # Get messages in thread
600
- rows = conn.execute(
601
- """SELECT m.*, a.name as from_name
602
- FROM messages m
603
- JOIN agents a ON m.sender_id = a.id
604
- WHERE m.thread_id = ? AND m.project_id = ?
605
- ORDER BY m.created_ts ASC""",
606
- (thread_id, project["id"])
607
- ).fetchall()
608
-
609
- # Build summary
610
- participants = list(set(row["from_name"] for row in rows))
611
-
612
- # Simple key points extraction (just use subjects for now)
613
- key_points = [row["subject"] for row in rows[:5]]
614
-
615
- # Action items (messages with "urgent" importance)
616
- action_items = [
617
- row["subject"] for row in rows
618
- if row["importance"] == "urgent"
619
- ]
620
-
621
- result = {
622
- "thread_id": thread_id,
623
- "summary": {
624
- "participants": participants,
625
- "key_points": key_points,
626
- "action_items": action_items,
627
- "total_messages": len(rows),
628
- }
629
- }
630
-
631
- if include_examples and rows:
632
- examples = []
633
- for row in rows[:3]:
634
- examples.append({
635
- "id": row["id"],
636
- "subject": row["subject"],
637
- "from": row["from_name"],
638
- "body_md": row["body_md"],
639
- })
640
- result["examples"] = examples
641
-
642
- return result
643
-
644
-
645
- async def tool_file_reservation_paths(args: dict[str, Any]) -> dict:
646
- """Reserve file paths for exclusive editing."""
647
- project_key = args.get("project_key")
648
- agent_name = args.get("agent_name")
649
- paths = args.get("paths", [])
650
- ttl_seconds = args.get("ttl_seconds", 3600)
651
- exclusive = args.get("exclusive", True)
652
- reason = args.get("reason", "")
653
-
654
- if not project_key:
655
- raise ValueError("project_key is required")
656
- if not agent_name:
657
- raise ValueError("agent_name is required")
658
- if not paths:
659
- raise ValueError("paths is required (list of path patterns)")
660
-
661
- with get_db() as conn:
662
- # Get project and agent
663
- project = conn.execute(
664
- "SELECT * FROM projects WHERE human_key = ?",
665
- (project_key,)
666
- ).fetchone()
667
- if not project:
668
- raise ValueError(f"Project not found: {project_key}")
669
-
670
- agent = conn.execute(
671
- "SELECT * FROM agents WHERE name = ? AND project_id = ?",
672
- (agent_name, project["id"])
673
- ).fetchone()
674
- if not agent:
675
- raise ValueError(f"Agent not found: {agent_name}")
676
-
677
- project_id = project["id"]
678
- agent_id = agent["id"]
679
-
680
- # Check for conflicts with existing reservations
681
- conflicts = []
682
- granted = []
683
- now = now_iso()
684
- expires = (datetime.now(timezone.utc) + timedelta(seconds=ttl_seconds)).isoformat()
685
-
686
- # Clean up expired reservations first
687
- conn.execute(
688
- "DELETE FROM file_reservations WHERE expires_ts < ?",
689
- (now,)
690
- )
691
-
692
- for path in paths:
693
- # Check for conflicting exclusive reservations
694
- # Simple matching: exact match or glob patterns
695
- conflicting = conn.execute(
696
- """SELECT fr.*, a.name as holder_name
697
- FROM file_reservations fr
698
- JOIN agents a ON fr.agent_id = a.id
699
- WHERE fr.project_id = ?
700
- AND fr.agent_id != ?
701
- AND fr.exclusive = 1
702
- AND (fr.path_pattern = ? OR fr.path_pattern LIKE ? OR ? LIKE fr.path_pattern)""",
703
- (project_id, agent_id, path, path.replace("*", "%"), path.replace("*", "%"))
704
- ).fetchall()
705
-
706
- if conflicting:
707
- conflicts.append({
708
- "path": path,
709
- "holders": [r["holder_name"] for r in conflicting],
710
- })
711
- else:
712
- # Grant the reservation
713
- cursor = conn.execute(
714
- """INSERT INTO file_reservations
715
- (project_id, agent_id, path_pattern, exclusive, reason, created_ts, expires_ts)
716
- VALUES (?, ?, ?, ?, ?, ?, ?)""",
717
- (project_id, agent_id, path, 1 if exclusive else 0, reason, now, expires)
718
- )
719
- granted.append({
720
- "id": cursor.lastrowid,
721
- "path_pattern": path,
722
- "exclusive": exclusive,
723
- "reason": reason,
724
- "expires_ts": expires,
725
- })
726
-
727
- return {
728
- "granted": granted,
729
- "conflicts": conflicts,
730
- }
731
-
732
-
733
- async def tool_release_file_reservations(args: dict[str, Any]) -> dict:
734
- """Release file reservations."""
735
- project_key = args.get("project_key")
736
- agent_name = args.get("agent_name")
737
- paths = args.get("paths")
738
- file_reservation_ids = args.get("file_reservation_ids")
739
-
740
- if not project_key:
741
- raise ValueError("project_key is required")
742
- if not agent_name:
743
- raise ValueError("agent_name is required")
744
-
745
- with get_db() as conn:
746
- # Get project and agent
747
- project = conn.execute(
748
- "SELECT * FROM projects WHERE human_key = ?",
749
- (project_key,)
750
- ).fetchone()
751
- if not project:
752
- raise ValueError(f"Project not found: {project_key}")
753
-
754
- agent = conn.execute(
755
- "SELECT * FROM agents WHERE name = ? AND project_id = ?",
756
- (agent_name, project["id"])
757
- ).fetchone()
758
- if not agent:
759
- raise ValueError(f"Agent not found: {agent_name}")
760
-
761
- # Build delete query
762
- if file_reservation_ids:
763
- # Delete by IDs
764
- placeholders = ",".join("?" * len(file_reservation_ids))
765
- cursor = conn.execute(
766
- f"""DELETE FROM file_reservations
767
- WHERE id IN ({placeholders}) AND agent_id = ?""",
768
- (*file_reservation_ids, agent["id"])
769
- )
770
- elif paths:
771
- # Delete by paths
772
- placeholders = ",".join("?" * len(paths))
773
- cursor = conn.execute(
774
- f"""DELETE FROM file_reservations
775
- WHERE path_pattern IN ({placeholders}) AND agent_id = ?""",
776
- (*paths, agent["id"])
777
- )
778
- else:
779
- # Delete all for this agent
780
- cursor = conn.execute(
781
- "DELETE FROM file_reservations WHERE agent_id = ?",
782
- (agent["id"],)
783
- )
784
-
785
- return {
786
- "released": cursor.rowcount,
787
- "released_at": now_iso(),
788
- }
789
-
790
-
791
- async def tool_acknowledge_message(args: dict[str, Any]) -> dict:
792
- """Acknowledge a message."""
793
- project_key = args.get("project_key")
794
- agent_name = args.get("agent_name")
795
- message_id = args.get("message_id")
796
-
797
- if not all([project_key, agent_name, message_id]):
798
- raise ValueError("project_key, agent_name, and message_id are required")
799
-
800
- with get_db() as conn:
801
- # Get agent
802
- project = conn.execute(
803
- "SELECT * FROM projects WHERE human_key = ?",
804
- (project_key,)
805
- ).fetchone()
806
- if not project:
807
- raise ValueError(f"Project not found: {project_key}")
808
-
809
- agent = conn.execute(
810
- "SELECT * FROM agents WHERE name = ? AND project_id = ?",
811
- (agent_name, project["id"])
812
- ).fetchone()
813
- if not agent:
814
- raise ValueError(f"Agent not found: {agent_name}")
815
-
816
- # Update ack timestamp
817
- now = now_iso()
818
- conn.execute(
819
- """UPDATE message_recipients
820
- SET acked_at = ?
821
- WHERE message_id = ? AND agent_id = ?""",
822
- (now, message_id, agent["id"])
823
- )
824
-
825
- return {"message_id": message_id, "acked_at": now}
826
-
827
-
828
- async def tool_search_messages(args: dict[str, Any]) -> list[dict]:
829
- """Search messages using FTS5."""
830
- project_key = args.get("project_key")
831
- query = args.get("query", "")
832
- limit = args.get("limit", 20)
833
-
834
- if not project_key:
835
- raise ValueError("project_key is required")
836
- if not query:
837
- raise ValueError("query is required")
838
-
839
- with get_db() as conn:
840
- # Get project
841
- project = conn.execute(
842
- "SELECT * FROM projects WHERE human_key = ?",
843
- (project_key,)
844
- ).fetchone()
845
- if not project:
846
- raise ValueError(f"Project not found: {project_key}")
847
-
848
- # Search using FTS5
849
- rows = conn.execute(
850
- """SELECT m.*, a.name as from_name
851
- FROM messages m
852
- JOIN messages_fts fts ON m.id = fts.rowid
853
- JOIN agents a ON m.sender_id = a.id
854
- WHERE m.project_id = ? AND messages_fts MATCH ?
855
- ORDER BY rank
856
- LIMIT ?""",
857
- (project["id"], query, limit)
858
- ).fetchall()
859
-
860
- return [
861
- {
862
- "id": row["id"],
863
- "subject": row["subject"],
864
- "from": row["from_name"],
865
- "created_ts": row["created_ts"],
866
- "importance": row["importance"],
867
- "thread_id": row["thread_id"],
868
- }
869
- for row in rows
870
- ]
871
-
872
-
873
- # =============================================================================
874
- # Main
875
- # =============================================================================
876
-
877
- if __name__ == "__main__":
878
- import uvicorn
879
- uvicorn.run(app, host="0.0.0.0", port=8765)