v3.0 - Conversation-based messaging system
Features: - Direct conversations (1-to-1) auto-created and permanent - Group conversations with leave/archive support - Real-time messaging via long-polling - Offline notifications via CLAUDE.md - Auto-registration on MCP startup Architecture: - Broker: Express HTTP server + SQLite - MCP Partner: Modular tools (one file per tool) - Full documentation and API reference
This commit is contained in:
parent
0bb8af199e
commit
66e5c677ea
78
CONTRIBUTING.md
Normal file
78
CONTRIBUTING.md
Normal file
@ -0,0 +1,78 @@
|
||||
# Contributing to MCP Claude Duo
|
||||
|
||||
Thanks for your interest in contributing!
|
||||
|
||||
## Getting Started
|
||||
|
||||
1. Fork the repository
|
||||
2. Clone your fork
|
||||
3. Install dependencies: `npm install`
|
||||
4. Start the broker: `npm run broker`
|
||||
|
||||
## Development
|
||||
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
mcp-claude-duo/
|
||||
├── broker/ # HTTP server + SQLite
|
||||
│ ├── index.js # Express routes
|
||||
│ └── db.js # Database layer
|
||||
├── mcp-partner/ # MCP server for Claude Code
|
||||
│ ├── index.js # Entry point
|
||||
│ ├── shared.js # Shared utilities
|
||||
│ └── tools/ # One file per MCP tool
|
||||
└── docs/ # Documentation
|
||||
```
|
||||
|
||||
### Adding a New Tool
|
||||
|
||||
1. Create a new file in `mcp-partner/tools/`
|
||||
2. Export `definition` (tool schema) and `handler` (async function)
|
||||
3. Import and register in `mcp-partner/index.js`
|
||||
|
||||
Example:
|
||||
```javascript
|
||||
import { brokerFetch, myId, ensureRegistered } from "../shared.js";
|
||||
|
||||
export const definition = {
|
||||
name: "my_tool",
|
||||
description: "Description of my tool",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
param: { type: "string", description: "A parameter" }
|
||||
},
|
||||
required: ["param"]
|
||||
}
|
||||
};
|
||||
|
||||
export async function handler(args) {
|
||||
await ensureRegistered();
|
||||
// Your logic here
|
||||
return {
|
||||
content: [{ type: "text", text: "Result" }]
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Testing
|
||||
|
||||
```bash
|
||||
# Start broker
|
||||
npm run broker
|
||||
|
||||
# Test with curl
|
||||
curl http://localhost:3210/health
|
||||
```
|
||||
|
||||
## Pull Requests
|
||||
|
||||
1. Create a feature branch
|
||||
2. Make your changes
|
||||
3. Test locally
|
||||
4. Submit a PR with a clear description
|
||||
|
||||
## Issues
|
||||
|
||||
Feel free to open issues for bugs, feature requests, or questions.
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 alexi
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
211
README.md
211
README.md
@ -1,118 +1,207 @@
|
||||
# MCP Claude Duo
|
||||
|
||||
MCP pour faire discuter plusieurs instances Claude Code ensemble.
|
||||
> Make multiple Claude Code instances talk to each other through conversations.
|
||||
|
||||
## Architecture v2
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
|
||||
## Overview
|
||||
|
||||
MCP Claude Duo is a [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) server that enables real-time communication between multiple Claude Code instances. Each Claude can send messages, create group conversations, and receive notifications when offline.
|
||||
|
||||
### Key Features
|
||||
|
||||
- **Direct Conversations** - Auto-created 1-to-1 threads between any two Claude instances
|
||||
- **Group Conversations** - Create named group chats with multiple participants
|
||||
- **Real-time Messaging** - Long-polling based instant message delivery
|
||||
- **Offline Notifications** - Messages are queued and notifications written to `CLAUDE.md`
|
||||
- **Auto-registration** - Claude instances automatically connect when launched
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||
│ Claude A │ │ Broker │ │ Claude B │
|
||||
│ (projet-a) │◄───►│ HTTP + SQLite │◄───►│ (projet-b) │
|
||||
│ + mcp-partner │ │ │ │ + mcp-partner │
|
||||
│ (project-a) │◄───►│ HTTP + SQLite │◄───►│ (project-b) │
|
||||
│ + mcp-partner │ │ Conversations │ │ + mcp-partner │
|
||||
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
||||
```
|
||||
|
||||
- **Un seul MCP unifié** : `mcp-partner` pour tout le monde
|
||||
- **Messages bufferisés** : SQLite stocke les messages, pas besoin d'être connecté en permanence
|
||||
- **Bidirectionnel** : tout le monde peut parler à tout le monde
|
||||
- **Broker**: Central HTTP server managing conversations and message routing
|
||||
- **MCP Partner**: MCP server running in each Claude Code instance
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
git clone https://github.com/YOUR_USER/mcp-claude-duo.git
|
||||
cd mcp-claude-duo
|
||||
npm install
|
||||
```
|
||||
|
||||
## Démarrage
|
||||
## Quick Start
|
||||
|
||||
### 1. Lancer le broker
|
||||
### 1. Start the Broker
|
||||
|
||||
```bash
|
||||
npm run broker
|
||||
```
|
||||
|
||||
Le broker tourne sur `http://localhost:3210` avec une base SQLite dans `data/duo.db`.
|
||||
The broker runs on `http://localhost:3210`.
|
||||
|
||||
### 2. Configurer le MCP (global)
|
||||
### 2. Configure MCP in Claude Code
|
||||
|
||||
**Global (all projects):**
|
||||
```bash
|
||||
claude mcp add duo-partner -s user -e BROKER_URL=http://localhost:3210 -- node "CHEMIN/mcp-claude-duo/mcp-partner/index.js"
|
||||
claude mcp add duo-partner -s user \
|
||||
-e BROKER_URL=http://localhost:3210 \
|
||||
-- node "/path/to/mcp-claude-duo/mcp-partner/index.js"
|
||||
```
|
||||
|
||||
Ou par projet :
|
||||
**Per project (with custom name):**
|
||||
```bash
|
||||
cd mon-projet
|
||||
claude mcp add duo-partner -s project -e BROKER_URL=http://localhost:3210 -e PARTNER_NAME="Mon Nom" -- node "CHEMIN/mcp-claude-duo/mcp-partner/index.js"
|
||||
claude mcp add duo-partner -s project \
|
||||
-e BROKER_URL=http://localhost:3210 \
|
||||
-e PARTNER_NAME="My Project" \
|
||||
-- node "/path/to/mcp-claude-duo/mcp-partner/index.js"
|
||||
```
|
||||
|
||||
## Tools disponibles
|
||||
### 3. Start Talking!
|
||||
|
||||
In any Claude Code instance:
|
||||
```
|
||||
talk("Hello!", to: "other_project")
|
||||
```
|
||||
|
||||
In the other instance:
|
||||
```
|
||||
listen()
|
||||
→ Message received from other_project: "Hello!"
|
||||
```
|
||||
|
||||
## MCP Tools
|
||||
|
||||
### Communication
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `register(name?)` | S'enregistrer sur le réseau |
|
||||
| `talk(message, to?)` | Envoyer un message et attendre la réponse |
|
||||
| `check_messages(wait?)` | Vérifier les messages en attente |
|
||||
| `listen()` | Écouter en temps réel (long-polling) |
|
||||
| `reply(message)` | Répondre au dernier message reçu |
|
||||
| `list_partners()` | Lister les partenaires connectés |
|
||||
| `history(partnerId, limit?)` | Historique de conversation |
|
||||
| `register(name?)` | Register with the network (optional, auto on startup) |
|
||||
| `talk(message, to?, conversation?)` | Send a message |
|
||||
| `listen(conversation?, timeout?)` | Listen for messages (2-15 min timeout) |
|
||||
| `list_partners()` | List connected partners |
|
||||
|
||||
## Exemples
|
||||
### Conversations
|
||||
|
||||
### Conversation simple
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `list_conversations()` | List your conversations |
|
||||
| `create_conversation(name, participants)` | Create a group conversation |
|
||||
| `leave_conversation(conversation)` | Leave a group |
|
||||
| `history(conversation, limit?)` | Get conversation history |
|
||||
|
||||
### Settings
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `set_status(message?)` | Set your status message |
|
||||
| `notifications(enabled)` | Enable/disable CLAUDE.md notifications |
|
||||
|
||||
## Examples
|
||||
|
||||
### Direct Conversation
|
||||
|
||||
**Claude A :**
|
||||
```
|
||||
register("Alice")
|
||||
talk("Salut, ça va ?")
|
||||
→ attend la réponse...
|
||||
→ "Bob: Oui et toi ?"
|
||||
```
|
||||
# Claude A
|
||||
talk("Hey, can you help with the auth module?", to: "project_b")
|
||||
|
||||
**Claude B :**
|
||||
```
|
||||
register("Bob")
|
||||
# Claude B
|
||||
listen()
|
||||
→ "Alice: Salut, ça va ?"
|
||||
reply("Oui et toi ?")
|
||||
→ 📁 direct_project_a_project_b
|
||||
[10:30] project_a: Hey, can you help with the auth module?
|
||||
|
||||
talk("Sure, what do you need?", to: "project_a")
|
||||
```
|
||||
|
||||
### Messages bufferisés
|
||||
### Group Conversation
|
||||
|
||||
**Claude A envoie même si B n'est pas connecté :**
|
||||
```
|
||||
talk("Hey, t'es là ?")
|
||||
→ message stocké en DB, attend la réponse...
|
||||
# Claude A creates a group
|
||||
create_conversation("Backend Team", "project_b, project_c")
|
||||
→ Created: group_1706123456789_abc123
|
||||
|
||||
# Anyone can send to the group
|
||||
talk("Sprint planning in 5 min", conversation: "group_1706123456789_abc123")
|
||||
```
|
||||
|
||||
**Claude B se connecte plus tard :**
|
||||
### Filtered Listening
|
||||
|
||||
```
|
||||
check_messages()
|
||||
→ "Alice: Hey, t'es là ?"
|
||||
reply("Oui, j'arrive !")
|
||||
→ Claude A reçoit la réponse
|
||||
# Listen only to a specific conversation
|
||||
listen(conversation: "direct_project_a_project_b", timeout: 10)
|
||||
|
||||
# Listen to all conversations
|
||||
listen(timeout: 5)
|
||||
```
|
||||
|
||||
## API Broker
|
||||
## Project Structure
|
||||
|
||||
| Endpoint | Description |
|
||||
|----------|-------------|
|
||||
| `POST /register` | S'enregistrer |
|
||||
| `POST /talk` | Envoyer et attendre réponse |
|
||||
| `GET /messages/:id` | Récupérer messages non lus |
|
||||
| `GET /wait/:id` | Long-polling |
|
||||
| `POST /respond` | Répondre à un message |
|
||||
| `GET /partners` | Lister les partenaires |
|
||||
| `GET /history/:a/:b` | Historique entre deux partenaires |
|
||||
| `GET /health` | Status du broker |
|
||||
```
|
||||
mcp-claude-duo/
|
||||
├── broker/
|
||||
│ ├── index.js # HTTP server & routes
|
||||
│ └── db.js # SQLite database layer
|
||||
├── mcp-partner/
|
||||
│ ├── index.js # MCP server entry point
|
||||
│ ├── shared.js # Shared utilities
|
||||
│ └── tools/ # One file per tool
|
||||
│ ├── register.js
|
||||
│ ├── talk.js
|
||||
│ ├── listen.js
|
||||
│ └── ...
|
||||
├── docs/
|
||||
│ ├── schema.sql # Database schema
|
||||
│ └── db-schema.md # Schema documentation
|
||||
└── data/ # SQLite database (gitignored)
|
||||
```
|
||||
|
||||
## Base de données
|
||||
## API Reference
|
||||
|
||||
SQLite dans `data/duo.db` :
|
||||
### Broker Endpoints
|
||||
|
||||
- `partners` : ID, nom, status, dernière connexion
|
||||
- `messages` : contenu, expéditeur, destinataire, timestamps
|
||||
| Endpoint | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| `/register` | POST | Register a partner |
|
||||
| `/talk` | POST | Send a message |
|
||||
| `/listen/:partnerId` | GET | Long-poll for messages |
|
||||
| `/conversations` | POST | Create group conversation |
|
||||
| `/conversations/:partnerId` | GET | List conversations |
|
||||
| `/conversations/:id/leave` | POST | Leave a conversation |
|
||||
| `/conversations/:id/messages` | GET | Get conversation history |
|
||||
| `/partners` | GET | List all partners |
|
||||
| `/health` | GET | Health check |
|
||||
|
||||
## Database
|
||||
|
||||
SQLite database with the following tables:
|
||||
|
||||
- **partners** - Registered Claude instances
|
||||
- **conversations** - Direct and group conversations
|
||||
- **conversation_participants** - Membership tracking
|
||||
- **messages** - All messages
|
||||
|
||||
See [docs/db-schema.md](docs/db-schema.md) for full schema documentation.
|
||||
|
||||
## Configuration
|
||||
|
||||
| Environment Variable | Default | Description |
|
||||
|---------------------|---------|-------------|
|
||||
| `BROKER_URL` | `http://localhost:3210` | Broker server URL |
|
||||
| `BROKER_PORT` | `3210` | Broker listen port |
|
||||
| `PARTNER_NAME` | `Claude` | Display name for the partner |
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
MIT - See [LICENSE](LICENSE) for details.
|
||||
|
||||
|
||||
284
broker/db.js
284
broker/db.js
@ -24,97 +24,172 @@ db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS partners (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
project_path TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
last_seen DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
status TEXT DEFAULT 'online'
|
||||
status TEXT DEFAULT 'online',
|
||||
status_message TEXT,
|
||||
notifications_enabled INTEGER DEFAULT 1
|
||||
);
|
||||
|
||||
-- Messages
|
||||
-- Conversations
|
||||
CREATE TABLE IF NOT EXISTS conversations (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT,
|
||||
type TEXT NOT NULL DEFAULT 'direct',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by TEXT,
|
||||
is_archived INTEGER DEFAULT 0,
|
||||
FOREIGN KEY (created_by) REFERENCES partners(id)
|
||||
);
|
||||
|
||||
-- Participants aux conversations
|
||||
CREATE TABLE IF NOT EXISTS conversation_participants (
|
||||
conversation_id TEXT NOT NULL,
|
||||
partner_id TEXT NOT NULL,
|
||||
joined_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
last_read_at DATETIME,
|
||||
PRIMARY KEY (conversation_id, partner_id),
|
||||
FOREIGN KEY (conversation_id) REFERENCES conversations(id),
|
||||
FOREIGN KEY (partner_id) REFERENCES partners(id)
|
||||
);
|
||||
|
||||
-- Messages (maintenant liés aux conversations)
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
conversation_id TEXT NOT NULL,
|
||||
from_id TEXT NOT NULL,
|
||||
to_id TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
request_id TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
delivered_at DATETIME,
|
||||
response_to INTEGER REFERENCES messages(id),
|
||||
FOREIGN KEY (from_id) REFERENCES partners(id),
|
||||
FOREIGN KEY (to_id) REFERENCES partners(id)
|
||||
FOREIGN KEY (conversation_id) REFERENCES conversations(id),
|
||||
FOREIGN KEY (from_id) REFERENCES partners(id)
|
||||
);
|
||||
|
||||
-- Index pour les requêtes fréquentes
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_to_id ON messages(to_id, delivered_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_request_id ON messages(request_id);
|
||||
-- Index
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_conversation ON messages(conversation_id, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_participants_partner ON conversation_participants(partner_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_conversations_archived ON conversations(is_archived);
|
||||
`);
|
||||
|
||||
// Génère un ID de conversation directe (déterministe, trié alphabétiquement)
|
||||
function getDirectConversationId(partnerId1, partnerId2) {
|
||||
const sorted = [partnerId1, partnerId2].sort();
|
||||
return `direct_${sorted[0]}_${sorted[1]}`;
|
||||
}
|
||||
|
||||
// Prepared statements
|
||||
const stmts = {
|
||||
// Partners
|
||||
upsertPartner: db.prepare(`
|
||||
INSERT INTO partners (id, name, last_seen, status)
|
||||
VALUES (?, ?, CURRENT_TIMESTAMP, 'online')
|
||||
INSERT INTO partners (id, name, project_path, last_seen, status)
|
||||
VALUES (?, ?, ?, CURRENT_TIMESTAMP, 'online')
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
project_path = excluded.project_path,
|
||||
last_seen = CURRENT_TIMESTAMP,
|
||||
status = 'online'
|
||||
`),
|
||||
|
||||
getPartner: db.prepare(`SELECT * FROM partners WHERE id = ?`),
|
||||
|
||||
getAllPartners: db.prepare(`SELECT * FROM partners ORDER BY last_seen DESC`),
|
||||
updatePartnerStatus: db.prepare(`UPDATE partners SET status = ?, last_seen = CURRENT_TIMESTAMP WHERE id = ?`),
|
||||
updatePartnerNotifications: db.prepare(`UPDATE partners SET notifications_enabled = ? WHERE id = ?`),
|
||||
updatePartnerStatusMessage: db.prepare(`UPDATE partners SET status_message = ?, last_seen = CURRENT_TIMESTAMP WHERE id = ?`),
|
||||
|
||||
updatePartnerStatus: db.prepare(`
|
||||
UPDATE partners SET status = ?, last_seen = CURRENT_TIMESTAMP WHERE id = ?
|
||||
// Conversations
|
||||
createConversation: db.prepare(`
|
||||
INSERT INTO conversations (id, name, type, created_by)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`),
|
||||
|
||||
getConversation: db.prepare(`SELECT * FROM conversations WHERE id = ?`),
|
||||
|
||||
getConversationsByPartner: db.prepare(`
|
||||
SELECT c.*,
|
||||
(SELECT COUNT(*) FROM messages m WHERE m.conversation_id = c.id
|
||||
AND m.created_at > COALESCE(cp.last_read_at, '1970-01-01')) as unread_count
|
||||
FROM conversations c
|
||||
JOIN conversation_participants cp ON c.id = cp.conversation_id
|
||||
WHERE cp.partner_id = ? AND c.is_archived = 0
|
||||
ORDER BY c.created_at DESC
|
||||
`),
|
||||
|
||||
archiveConversation: db.prepare(`UPDATE conversations SET is_archived = 1 WHERE id = ?`),
|
||||
|
||||
// Participants
|
||||
addParticipant: db.prepare(`
|
||||
INSERT OR IGNORE INTO conversation_participants (conversation_id, partner_id)
|
||||
VALUES (?, ?)
|
||||
`),
|
||||
|
||||
removeParticipant: db.prepare(`
|
||||
DELETE FROM conversation_participants WHERE conversation_id = ? AND partner_id = ?
|
||||
`),
|
||||
|
||||
getParticipants: db.prepare(`
|
||||
SELECT p.* FROM partners p
|
||||
JOIN conversation_participants cp ON p.id = cp.partner_id
|
||||
WHERE cp.conversation_id = ?
|
||||
`),
|
||||
|
||||
countParticipants: db.prepare(`
|
||||
SELECT COUNT(*) as count FROM conversation_participants WHERE conversation_id = ?
|
||||
`),
|
||||
|
||||
isParticipant: db.prepare(`
|
||||
SELECT 1 FROM conversation_participants WHERE conversation_id = ? AND partner_id = ?
|
||||
`),
|
||||
|
||||
updateLastRead: db.prepare(`
|
||||
UPDATE conversation_participants SET last_read_at = CURRENT_TIMESTAMP
|
||||
WHERE conversation_id = ? AND partner_id = ?
|
||||
`),
|
||||
|
||||
getLastRead: db.prepare(`
|
||||
SELECT last_read_at FROM conversation_participants
|
||||
WHERE conversation_id = ? AND partner_id = ?
|
||||
`),
|
||||
|
||||
// Messages
|
||||
insertMessage: db.prepare(`
|
||||
INSERT INTO messages (from_id, to_id, content, request_id)
|
||||
VALUES (?, ?, ?, ?)
|
||||
INSERT INTO messages (conversation_id, from_id, content)
|
||||
VALUES (?, ?, ?)
|
||||
`),
|
||||
|
||||
getUndeliveredMessages: db.prepare(`
|
||||
getMessages: db.prepare(`
|
||||
SELECT * FROM messages WHERE conversation_id = ?
|
||||
ORDER BY created_at ASC
|
||||
LIMIT ?
|
||||
`),
|
||||
|
||||
getMessagesSince: db.prepare(`
|
||||
SELECT * FROM messages
|
||||
WHERE to_id = ? AND delivered_at IS NULL
|
||||
WHERE conversation_id = ? AND created_at > ?
|
||||
ORDER BY created_at ASC
|
||||
`),
|
||||
|
||||
markDelivered: db.prepare(`
|
||||
UPDATE messages SET delivered_at = CURRENT_TIMESTAMP WHERE id = ?
|
||||
getUnreadMessages: db.prepare(`
|
||||
SELECT m.* FROM messages m
|
||||
JOIN conversation_participants cp ON m.conversation_id = cp.conversation_id
|
||||
WHERE cp.partner_id = ? AND m.from_id != ?
|
||||
AND m.created_at > COALESCE(cp.last_read_at, '1970-01-01')
|
||||
ORDER BY m.created_at ASC
|
||||
`),
|
||||
|
||||
getMessageByRequestId: db.prepare(`
|
||||
SELECT * FROM messages WHERE request_id = ?
|
||||
`),
|
||||
|
||||
insertResponse: db.prepare(`
|
||||
INSERT INTO messages (from_id, to_id, content, response_to)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`),
|
||||
|
||||
getResponse: db.prepare(`
|
||||
SELECT * FROM messages WHERE response_to = ? AND delivered_at IS NULL
|
||||
`),
|
||||
|
||||
markResponseDelivered: db.prepare(`
|
||||
UPDATE messages SET delivered_at = CURRENT_TIMESTAMP WHERE response_to = ?
|
||||
`),
|
||||
|
||||
// Conversations history
|
||||
getConversation: db.prepare(`
|
||||
SELECT * FROM messages
|
||||
WHERE (from_id = ? AND to_id = ?) OR (from_id = ? AND to_id = ?)
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ?
|
||||
getUnreadMessagesInConv: db.prepare(`
|
||||
SELECT m.* FROM messages m
|
||||
JOIN conversation_participants cp ON m.conversation_id = cp.conversation_id
|
||||
WHERE cp.partner_id = ? AND m.conversation_id = ? AND m.from_id != ?
|
||||
AND m.created_at > COALESCE(cp.last_read_at, '1970-01-01')
|
||||
ORDER BY m.created_at ASC
|
||||
`),
|
||||
};
|
||||
|
||||
// API
|
||||
export const DB = {
|
||||
// Partners
|
||||
registerPartner(id, name) {
|
||||
stmts.upsertPartner.run(id, name);
|
||||
registerPartner(id, name, projectPath = null) {
|
||||
stmts.upsertPartner.run(id, name, projectPath);
|
||||
return stmts.getPartner.get(id);
|
||||
},
|
||||
|
||||
@ -134,47 +209,104 @@ export const DB = {
|
||||
stmts.updatePartnerStatus.run("online", id);
|
||||
},
|
||||
|
||||
setNotificationsEnabled(id, enabled) {
|
||||
stmts.updatePartnerNotifications.run(enabled ? 1 : 0, id);
|
||||
},
|
||||
|
||||
setStatusMessage(id, message) {
|
||||
stmts.updatePartnerStatusMessage.run(message, id);
|
||||
},
|
||||
|
||||
// Conversations
|
||||
getOrCreateDirectConversation(partnerId1, partnerId2) {
|
||||
const convId = getDirectConversationId(partnerId1, partnerId2);
|
||||
let conv = stmts.getConversation.get(convId);
|
||||
|
||||
if (!conv) {
|
||||
stmts.createConversation.run(convId, null, "direct", partnerId1);
|
||||
stmts.addParticipant.run(convId, partnerId1);
|
||||
stmts.addParticipant.run(convId, partnerId2);
|
||||
conv = stmts.getConversation.get(convId);
|
||||
}
|
||||
|
||||
return conv;
|
||||
},
|
||||
|
||||
createGroupConversation(name, creatorId, participantIds) {
|
||||
const convId = `group_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
stmts.createConversation.run(convId, name, "group", creatorId);
|
||||
|
||||
// Ajouter le créateur et tous les participants
|
||||
stmts.addParticipant.run(convId, creatorId);
|
||||
for (const pid of participantIds) {
|
||||
if (pid !== creatorId) {
|
||||
stmts.addParticipant.run(convId, pid);
|
||||
}
|
||||
}
|
||||
|
||||
return stmts.getConversation.get(convId);
|
||||
},
|
||||
|
||||
getConversation(convId) {
|
||||
return stmts.getConversation.get(convId);
|
||||
},
|
||||
|
||||
getConversationsByPartner(partnerId) {
|
||||
return stmts.getConversationsByPartner.all(partnerId);
|
||||
},
|
||||
|
||||
getParticipants(convId) {
|
||||
return stmts.getParticipants.all(convId);
|
||||
},
|
||||
|
||||
isParticipant(convId, partnerId) {
|
||||
return !!stmts.isParticipant.get(convId, partnerId);
|
||||
},
|
||||
|
||||
addParticipant(convId, partnerId) {
|
||||
stmts.addParticipant.run(convId, partnerId);
|
||||
},
|
||||
|
||||
leaveConversation(convId, partnerId) {
|
||||
const conv = stmts.getConversation.get(convId);
|
||||
if (!conv) return { error: "Conversation not found" };
|
||||
if (conv.type === "direct") return { error: "Cannot leave a direct conversation" };
|
||||
|
||||
stmts.removeParticipant.run(convId, partnerId);
|
||||
|
||||
// Vérifier s'il reste des participants
|
||||
const count = stmts.countParticipants.get(convId).count;
|
||||
if (count === 0) {
|
||||
stmts.archiveConversation.run(convId);
|
||||
return { left: true, archived: true };
|
||||
}
|
||||
|
||||
return { left: true, archived: false };
|
||||
},
|
||||
|
||||
// Messages
|
||||
sendMessage(fromId, toId, content, requestId = null) {
|
||||
const result = stmts.insertMessage.run(fromId, toId, content, requestId);
|
||||
sendMessage(convId, fromId, content) {
|
||||
const result = stmts.insertMessage.run(convId, fromId, content);
|
||||
return result.lastInsertRowid;
|
||||
},
|
||||
|
||||
getUndeliveredMessages(toId) {
|
||||
return stmts.getUndeliveredMessages.all(toId);
|
||||
getMessages(convId, limit = 50) {
|
||||
return stmts.getMessages.all(convId, limit);
|
||||
},
|
||||
|
||||
markDelivered(messageId) {
|
||||
stmts.markDelivered.run(messageId);
|
||||
getUnreadMessages(partnerId) {
|
||||
return stmts.getUnreadMessages.all(partnerId, partnerId);
|
||||
},
|
||||
|
||||
// Pour talk() qui attend une réponse
|
||||
sendAndWaitResponse(fromId, toId, content, requestId) {
|
||||
stmts.insertMessage.run(fromId, toId, content, requestId);
|
||||
getUnreadMessagesInConv(partnerId, convId) {
|
||||
return stmts.getUnreadMessagesInConv.all(partnerId, convId, partnerId);
|
||||
},
|
||||
|
||||
getMessageByRequestId(requestId) {
|
||||
return stmts.getMessageByRequestId.get(requestId);
|
||||
markConversationRead(convId, partnerId) {
|
||||
stmts.updateLastRead.run(convId, partnerId);
|
||||
},
|
||||
|
||||
sendResponse(fromId, toId, content, originalMessageId) {
|
||||
stmts.insertResponse.run(fromId, toId, content, originalMessageId);
|
||||
},
|
||||
|
||||
getResponse(originalMessageId) {
|
||||
return stmts.getResponse.get(originalMessageId);
|
||||
},
|
||||
|
||||
markResponseDelivered(originalMessageId) {
|
||||
stmts.markResponseDelivered.run(originalMessageId);
|
||||
},
|
||||
|
||||
// History
|
||||
getConversation(partnerId1, partnerId2, limit = 50) {
|
||||
return stmts.getConversation.all(partnerId1, partnerId2, partnerId2, partnerId1, limit);
|
||||
},
|
||||
|
||||
// Raw access for complex queries
|
||||
// Raw access
|
||||
raw: db,
|
||||
};
|
||||
|
||||
|
||||
462
broker/index.js
462
broker/index.js
@ -1,4 +1,6 @@
|
||||
import express from "express";
|
||||
import { existsSync, readFileSync, writeFileSync } from "fs";
|
||||
import { join } from "path";
|
||||
import { DB } from "./db.js";
|
||||
|
||||
const app = express();
|
||||
@ -6,151 +8,396 @@ app.use(express.json());
|
||||
|
||||
const PORT = process.env.BROKER_PORT || 3210;
|
||||
|
||||
// Réponses en attente (pour talk qui attend une réponse)
|
||||
// { requestId: { resolve, fromId, toId } }
|
||||
const pendingResponses = new Map();
|
||||
|
||||
// Long-polling en attente (pour check_messages)
|
||||
// { partnerId: { res, heartbeat } }
|
||||
// Partenaires en écoute (long-polling)
|
||||
// { visitorId: { res, heartbeat, timeout, conversationId? } }
|
||||
const waitingPartners = new Map();
|
||||
|
||||
/**
|
||||
* Écrit une notification dans le CLAUDE.md du projet destinataire
|
||||
*/
|
||||
function writeNotificationToClaudeMd(partnerId, fromId, convId, content) {
|
||||
const partner = DB.getPartner(partnerId);
|
||||
if (!partner || !partner.project_path || !partner.notifications_enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const claudeMdPath = join(partner.project_path, "CLAUDE.md");
|
||||
const notificationMarker = "<!-- CLAUDE-DUO-NOTIFICATIONS -->";
|
||||
const endMarker = "<!-- /CLAUDE-DUO-NOTIFICATIONS -->";
|
||||
|
||||
const timestamp = new Date().toLocaleString();
|
||||
const convLabel = convId.startsWith("direct_") ? `DM de ${fromId}` : `[${convId}]`;
|
||||
const newNotification = `- **[${timestamp}] ${convLabel}:** ${content.substring(0, 200)}${content.length > 200 ? "..." : ""}`;
|
||||
|
||||
let claudeMdContent = "";
|
||||
if (existsSync(claudeMdPath)) {
|
||||
claudeMdContent = readFileSync(claudeMdPath, "utf-8");
|
||||
}
|
||||
|
||||
const startIdx = claudeMdContent.indexOf(notificationMarker);
|
||||
const endIdx = claudeMdContent.indexOf(endMarker);
|
||||
|
||||
let notificationsSection = `
|
||||
${notificationMarker}
|
||||
## PRIORITE: Messages en attente (Claude Duo)
|
||||
|
||||
**ACTION REQUISE: Tu as des messages non lus. Utilise \`listen\` pour les lire.**
|
||||
|
||||
${newNotification}
|
||||
|
||||
${endMarker}`;
|
||||
|
||||
if (startIdx !== -1 && endIdx !== -1) {
|
||||
const existingSection = claudeMdContent.substring(startIdx + notificationMarker.length, endIdx);
|
||||
const cleanedNotifications = existingSection
|
||||
.replace("## PRIORITE: Messages en attente (Claude Duo)", "")
|
||||
.replace(/\*\*ACTION REQUISE:.*\*\*/g, "")
|
||||
.trim();
|
||||
|
||||
notificationsSection = `
|
||||
${notificationMarker}
|
||||
## PRIORITE: Messages en attente (Claude Duo)
|
||||
|
||||
**ACTION REQUISE: Tu as des messages non lus. Utilise \`listen\` pour les lire.**
|
||||
|
||||
${newNotification}
|
||||
${cleanedNotifications}
|
||||
|
||||
${endMarker}`;
|
||||
|
||||
const beforeSection = claudeMdContent.substring(0, startIdx);
|
||||
const afterSection = claudeMdContent.substring(endIdx + endMarker.length);
|
||||
claudeMdContent = beforeSection.trimEnd() + "\n" + notificationsSection + afterSection;
|
||||
} else {
|
||||
claudeMdContent = claudeMdContent.trimEnd() + "\n" + notificationsSection;
|
||||
}
|
||||
|
||||
try {
|
||||
writeFileSync(claudeMdPath, claudeMdContent);
|
||||
console.log(`[BROKER] Notification written to ${claudeMdPath}`);
|
||||
} catch (err) {
|
||||
console.error(`[BROKER] Failed to write notification to ${claudeMdPath}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Supprime les notifications du CLAUDE.md
|
||||
*/
|
||||
function clearNotificationsFromClaudeMd(partnerId) {
|
||||
const partner = DB.getPartner(partnerId);
|
||||
if (!partner || !partner.project_path) return;
|
||||
|
||||
const claudeMdPath = join(partner.project_path, "CLAUDE.md");
|
||||
if (!existsSync(claudeMdPath)) return;
|
||||
|
||||
const notificationMarker = "<!-- CLAUDE-DUO-NOTIFICATIONS -->";
|
||||
const endMarker = "<!-- /CLAUDE-DUO-NOTIFICATIONS -->";
|
||||
|
||||
let claudeMdContent = readFileSync(claudeMdPath, "utf-8");
|
||||
const startIdx = claudeMdContent.indexOf(notificationMarker);
|
||||
const endIdx = claudeMdContent.indexOf(endMarker);
|
||||
|
||||
if (startIdx !== -1 && endIdx !== -1) {
|
||||
const beforeSection = claudeMdContent.substring(0, startIdx);
|
||||
const afterSection = claudeMdContent.substring(endIdx + endMarker.length);
|
||||
claudeMdContent = (beforeSection.trimEnd() + afterSection).trim() + "\n";
|
||||
writeFileSync(claudeMdPath, claudeMdContent);
|
||||
console.log(`[BROKER] Notifications cleared from ${claudeMdPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Notifie un partenaire en attente qu'il a des messages
|
||||
*/
|
||||
function notifyWaitingPartner(partnerId, conversationId = null) {
|
||||
if (waitingPartners.has(partnerId)) {
|
||||
const { res, heartbeat, timeout, conversationId: listeningConvId } = waitingPartners.get(partnerId);
|
||||
|
||||
// Si le partenaire écoute une conv spécifique, ne notifier que pour celle-là
|
||||
if (listeningConvId && conversationId && listeningConvId !== conversationId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
clearInterval(heartbeat);
|
||||
if (timeout) clearTimeout(timeout);
|
||||
waitingPartners.delete(partnerId);
|
||||
|
||||
// Récupérer les messages non lus
|
||||
let messages;
|
||||
if (listeningConvId) {
|
||||
messages = DB.getUnreadMessagesInConv(partnerId, listeningConvId);
|
||||
DB.markConversationRead(listeningConvId, partnerId);
|
||||
} else {
|
||||
messages = DB.getUnreadMessages(partnerId);
|
||||
// Marquer toutes les convs comme lues
|
||||
const convIds = [...new Set(messages.map(m => m.conversation_id))];
|
||||
for (const cid of convIds) {
|
||||
DB.markConversationRead(cid, partnerId);
|
||||
}
|
||||
}
|
||||
|
||||
if (messages.length > 0) {
|
||||
clearNotificationsFromClaudeMd(partnerId);
|
||||
}
|
||||
|
||||
try {
|
||||
res.json({ hasMessages: true, messages });
|
||||
} catch {}
|
||||
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ============ ROUTES ============
|
||||
|
||||
/**
|
||||
* S'enregistrer
|
||||
* POST /register
|
||||
*/
|
||||
app.post("/register", (req, res) => {
|
||||
const { partnerId, name } = req.body;
|
||||
const { partnerId, name, projectPath } = req.body;
|
||||
|
||||
if (!partnerId) {
|
||||
return res.status(400).json({ error: "partnerId required" });
|
||||
}
|
||||
|
||||
const partner = DB.registerPartner(partnerId, name || partnerId);
|
||||
const partner = DB.registerPartner(partnerId, name || partnerId, projectPath);
|
||||
console.log(`[BROKER] Registered: ${partner.name} (${partnerId})`);
|
||||
|
||||
res.json({ success: true, partner });
|
||||
});
|
||||
|
||||
/**
|
||||
* Envoyer un message et attendre la réponse
|
||||
* Envoyer un message dans une conversation
|
||||
* POST /talk
|
||||
* Body: { fromId, to?, conversationId?, content }
|
||||
* - to: pour créer/trouver une conv directe
|
||||
* - conversationId: pour envoyer dans une conv existante
|
||||
*/
|
||||
app.post("/talk", (req, res) => {
|
||||
const { fromId, toId, content } = req.body;
|
||||
const { fromId, to, conversationId, content } = req.body;
|
||||
|
||||
if (!fromId || !toId || !content) {
|
||||
return res.status(400).json({ error: "fromId, toId, and content required" });
|
||||
if (!fromId || !content) {
|
||||
return res.status(400).json({ error: "fromId and content required" });
|
||||
}
|
||||
|
||||
const requestId = `req_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
if (!to && !conversationId) {
|
||||
return res.status(400).json({ error: "Either 'to' or 'conversationId' required" });
|
||||
}
|
||||
|
||||
// Enregistrer le message en DB
|
||||
const messageId = DB.sendMessage(fromId, toId, content, requestId);
|
||||
let conv;
|
||||
let targetIds = [];
|
||||
|
||||
console.log(`[BROKER] ${fromId} -> ${toId}: "${content.substring(0, 50)}..."`);
|
||||
if (conversationId) {
|
||||
// Envoyer dans une conv existante
|
||||
conv = DB.getConversation(conversationId);
|
||||
if (!conv) {
|
||||
return res.status(404).json({ error: "Conversation not found" });
|
||||
}
|
||||
if (!DB.isParticipant(conversationId, fromId)) {
|
||||
return res.status(403).json({ error: "Not a participant of this conversation" });
|
||||
}
|
||||
targetIds = DB.getParticipants(conversationId).map(p => p.id).filter(id => id !== fromId);
|
||||
} else {
|
||||
// Conversation directe
|
||||
const recipient = DB.getPartner(to);
|
||||
if (!recipient) {
|
||||
return res.status(404).json({
|
||||
error: "Destinataire inconnu",
|
||||
message: `"${to}" n'est pas enregistré. Il doit se register d'abord.`
|
||||
});
|
||||
}
|
||||
conv = DB.getOrCreateDirectConversation(fromId, to);
|
||||
targetIds = [to];
|
||||
}
|
||||
|
||||
// Notifier le destinataire s'il est en attente
|
||||
notifyWaitingPartner(toId);
|
||||
// Envoyer le message
|
||||
const msgId = DB.sendMessage(conv.id, fromId, content);
|
||||
console.log(`[BROKER] ${fromId} -> ${conv.id}: "${content.substring(0, 50)}..."`);
|
||||
|
||||
// Attendre la réponse (pas de timeout)
|
||||
const responsePromise = new Promise((resolve) => {
|
||||
pendingResponses.set(requestId, { resolve, fromId, toId, messageId });
|
||||
});
|
||||
// Notifier les participants
|
||||
let notifiedCount = 0;
|
||||
for (const targetId of targetIds) {
|
||||
const notified = notifyWaitingPartner(targetId, conv.id);
|
||||
if (notified) {
|
||||
notifiedCount++;
|
||||
} else {
|
||||
// Pas en écoute, écrire notification
|
||||
writeNotificationToClaudeMd(targetId, fromId, conv.id, content);
|
||||
}
|
||||
}
|
||||
|
||||
responsePromise.then((response) => {
|
||||
res.json(response);
|
||||
res.json({
|
||||
success: true,
|
||||
conversationId: conv.id,
|
||||
messageId: msgId,
|
||||
notified: notifiedCount,
|
||||
queued: targetIds.length - notifiedCount
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Récupérer les messages non lus
|
||||
* GET /messages/:partnerId
|
||||
* Écouter les messages (long-polling)
|
||||
* GET /listen/:partnerId?conversationId=xxx&timeout=5
|
||||
*/
|
||||
app.get("/messages/:partnerId", (req, res) => {
|
||||
app.get("/listen/:partnerId", (req, res) => {
|
||||
const { partnerId } = req.params;
|
||||
const { conversationId } = req.query;
|
||||
|
||||
const messages = DB.getUndeliveredMessages(partnerId);
|
||||
// Timeout en minutes (min 2, max 15, défaut 2)
|
||||
let timeoutMinutes = parseInt(req.query.timeout) || 2;
|
||||
timeoutMinutes = Math.max(2, Math.min(15, timeoutMinutes));
|
||||
const timeoutMs = timeoutMinutes * 60 * 1000;
|
||||
|
||||
// Marquer comme délivrés
|
||||
for (const msg of messages) {
|
||||
DB.markDelivered(msg.id);
|
||||
}
|
||||
|
||||
res.json({ messages });
|
||||
});
|
||||
|
||||
/**
|
||||
* Attendre des messages (long-polling)
|
||||
* GET /wait/:partnerId
|
||||
*/
|
||||
app.get("/wait/:partnerId", (req, res) => {
|
||||
const { partnerId } = req.params;
|
||||
|
||||
// Mettre à jour le status
|
||||
DB.setPartnerOnline(partnerId);
|
||||
|
||||
// Check s'il y a des messages en attente
|
||||
const messages = DB.getUndeliveredMessages(partnerId);
|
||||
if (messages.length > 0) {
|
||||
// Marquer comme délivrés
|
||||
for (const msg of messages) {
|
||||
DB.markDelivered(msg.id);
|
||||
// Vérifier s'il y a des messages non lus
|
||||
let messages;
|
||||
if (conversationId) {
|
||||
if (!DB.isParticipant(conversationId, partnerId)) {
|
||||
return res.status(403).json({ error: "Not a participant of this conversation" });
|
||||
}
|
||||
messages = DB.getUnreadMessagesInConv(partnerId, conversationId);
|
||||
} else {
|
||||
messages = DB.getUnreadMessages(partnerId);
|
||||
}
|
||||
|
||||
if (messages.length > 0) {
|
||||
// Marquer comme lu
|
||||
const convIds = [...new Set(messages.map(m => m.conversation_id))];
|
||||
for (const cid of convIds) {
|
||||
DB.markConversationRead(cid, partnerId);
|
||||
}
|
||||
clearNotificationsFromClaudeMd(partnerId);
|
||||
return res.json({ hasMessages: true, messages });
|
||||
}
|
||||
|
||||
// Annuler l'ancien waiting s'il existe
|
||||
// Pas de messages, on attend
|
||||
if (waitingPartners.has(partnerId)) {
|
||||
const old = waitingPartners.get(partnerId);
|
||||
if (old.heartbeat) clearInterval(old.heartbeat);
|
||||
old.res.json({ hasMessages: false, messages: [], reason: "reconnect" });
|
||||
if (old.timeout) clearTimeout(old.timeout);
|
||||
try {
|
||||
old.res.json({ hasMessages: false, messages: [], reason: "reconnect" });
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Heartbeat toutes les 30s
|
||||
const heartbeat = setInterval(() => {
|
||||
try {
|
||||
res.write(": heartbeat\n\n");
|
||||
} catch (e) {
|
||||
clearInterval(heartbeat);
|
||||
const timeout = setTimeout(() => {
|
||||
if (waitingPartners.has(partnerId)) {
|
||||
const waiting = waitingPartners.get(partnerId);
|
||||
clearInterval(waiting.heartbeat);
|
||||
waitingPartners.delete(partnerId);
|
||||
try {
|
||||
res.json({ hasMessages: false, messages: [], reason: "timeout", timeoutMinutes });
|
||||
} catch {}
|
||||
}
|
||||
}, 30000);
|
||||
}, timeoutMs);
|
||||
|
||||
const heartbeat = setInterval(() => {}, 30000);
|
||||
|
||||
// Nettoyer quand la connexion se ferme
|
||||
res.on("close", () => {
|
||||
clearInterval(heartbeat);
|
||||
clearTimeout(timeout);
|
||||
waitingPartners.delete(partnerId);
|
||||
DB.setPartnerOffline(partnerId);
|
||||
console.log(`[BROKER] ${partnerId} disconnected`);
|
||||
});
|
||||
|
||||
waitingPartners.set(partnerId, { res, heartbeat });
|
||||
waitingPartners.set(partnerId, { res, heartbeat, timeout, conversationId });
|
||||
console.log(`[BROKER] ${partnerId} is now listening${conversationId ? ` on ${conversationId}` : ""}`);
|
||||
});
|
||||
|
||||
/**
|
||||
* Répondre à un message
|
||||
* POST /respond
|
||||
* Créer une conversation de groupe
|
||||
* POST /conversations
|
||||
* Body: { creatorId, name, participants: [] }
|
||||
*/
|
||||
app.post("/respond", (req, res) => {
|
||||
const { fromId, toId, content, requestId } = req.body;
|
||||
app.post("/conversations", (req, res) => {
|
||||
const { creatorId, name, participants } = req.body;
|
||||
|
||||
console.log(`[BROKER] ${fromId} responded to ${toId}: "${content.substring(0, 50)}..."`);
|
||||
|
||||
// Trouver la requête en attente
|
||||
if (requestId && pendingResponses.has(requestId)) {
|
||||
const { resolve, messageId } = pendingResponses.get(requestId);
|
||||
pendingResponses.delete(requestId);
|
||||
|
||||
// Enregistrer la réponse en DB
|
||||
DB.sendResponse(fromId, toId, content, messageId);
|
||||
|
||||
resolve({ success: true, content });
|
||||
} else {
|
||||
// Pas de requête en attente, juste enregistrer comme message normal
|
||||
DB.sendMessage(fromId, toId, content, null);
|
||||
notifyWaitingPartner(toId);
|
||||
if (!creatorId || !name || !participants?.length) {
|
||||
return res.status(400).json({ error: "creatorId, name, and participants required" });
|
||||
}
|
||||
|
||||
res.json({ success: true });
|
||||
// Vérifier que tous les participants existent
|
||||
for (const pid of participants) {
|
||||
if (!DB.getPartner(pid)) {
|
||||
return res.status(404).json({ error: `Partner "${pid}" not found` });
|
||||
}
|
||||
}
|
||||
|
||||
const conv = DB.createGroupConversation(name, creatorId, participants);
|
||||
console.log(`[BROKER] Group conversation created: ${conv.id} by ${creatorId}`);
|
||||
|
||||
res.json({ success: true, conversation: conv });
|
||||
});
|
||||
|
||||
/**
|
||||
* Lister les conversations d'un partenaire
|
||||
* GET /conversations/:partnerId
|
||||
*/
|
||||
app.get("/conversations/:partnerId", (req, res) => {
|
||||
const { partnerId } = req.params;
|
||||
const conversations = DB.getConversationsByPartner(partnerId);
|
||||
|
||||
// Ajouter les participants à chaque conversation
|
||||
const convsWithParticipants = conversations.map(conv => ({
|
||||
...conv,
|
||||
participants: DB.getParticipants(conv.id).map(p => ({ id: p.id, name: p.name }))
|
||||
}));
|
||||
|
||||
res.json({ conversations: convsWithParticipants });
|
||||
});
|
||||
|
||||
/**
|
||||
* Quitter une conversation
|
||||
* POST /conversations/:conversationId/leave
|
||||
* Body: { partnerId }
|
||||
*/
|
||||
app.post("/conversations/:conversationId/leave", (req, res) => {
|
||||
const { conversationId } = req.params;
|
||||
const { partnerId } = req.body;
|
||||
|
||||
if (!partnerId) {
|
||||
return res.status(400).json({ error: "partnerId required" });
|
||||
}
|
||||
|
||||
const result = DB.leaveConversation(conversationId, partnerId);
|
||||
|
||||
if (result.error) {
|
||||
return res.status(400).json({ error: result.error });
|
||||
}
|
||||
|
||||
console.log(`[BROKER] ${partnerId} left ${conversationId}${result.archived ? " (archived)" : ""}`);
|
||||
res.json({ success: true, ...result });
|
||||
});
|
||||
|
||||
/**
|
||||
* Obtenir l'historique d'une conversation
|
||||
* GET /conversations/:conversationId/messages?limit=50
|
||||
*/
|
||||
app.get("/conversations/:conversationId/messages", (req, res) => {
|
||||
const { conversationId } = req.params;
|
||||
const limit = parseInt(req.query.limit) || 50;
|
||||
|
||||
const conv = DB.getConversation(conversationId);
|
||||
if (!conv) {
|
||||
return res.status(404).json({ error: "Conversation not found" });
|
||||
}
|
||||
|
||||
const messages = DB.getMessages(conversationId, limit);
|
||||
res.json({ conversation: conv, messages });
|
||||
});
|
||||
|
||||
/**
|
||||
* Obtenir les participants d'une conversation
|
||||
* GET /conversations/:conversationId/participants
|
||||
*/
|
||||
app.get("/conversations/:conversationId/participants", (req, res) => {
|
||||
const { conversationId } = req.params;
|
||||
const participants = DB.getParticipants(conversationId);
|
||||
res.json({ participants });
|
||||
});
|
||||
|
||||
/**
|
||||
@ -158,20 +405,33 @@ app.post("/respond", (req, res) => {
|
||||
* GET /partners
|
||||
*/
|
||||
app.get("/partners", (req, res) => {
|
||||
const partners = DB.getAllPartners();
|
||||
const partners = DB.getAllPartners().map((p) => ({
|
||||
...p,
|
||||
isListening: waitingPartners.has(p.id),
|
||||
}));
|
||||
res.json({ partners });
|
||||
});
|
||||
|
||||
/**
|
||||
* Historique de conversation
|
||||
* GET /history/:partner1/:partner2
|
||||
* Définir le status message d'un partenaire
|
||||
* POST /partners/:partnerId/status
|
||||
*/
|
||||
app.get("/history/:partner1/:partner2", (req, res) => {
|
||||
const { partner1, partner2 } = req.params;
|
||||
const limit = parseInt(req.query.limit) || 50;
|
||||
app.post("/partners/:partnerId/status", (req, res) => {
|
||||
const { partnerId } = req.params;
|
||||
const { message } = req.body;
|
||||
DB.setStatusMessage(partnerId, message || null);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
const messages = DB.getConversation(partner1, partner2, limit);
|
||||
res.json({ messages });
|
||||
/**
|
||||
* Activer/désactiver les notifications
|
||||
* POST /partners/:partnerId/notifications
|
||||
*/
|
||||
app.post("/partners/:partnerId/notifications", (req, res) => {
|
||||
const { partnerId } = req.params;
|
||||
const { enabled } = req.body;
|
||||
DB.setNotificationsEnabled(partnerId, enabled);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
/**
|
||||
@ -180,28 +440,10 @@ app.get("/history/:partner1/:partner2", (req, res) => {
|
||||
app.get("/health", (req, res) => {
|
||||
const partners = DB.getAllPartners();
|
||||
const online = partners.filter((p) => p.status === "online").length;
|
||||
res.json({ status: "ok", partners: partners.length, online });
|
||||
const listening = waitingPartners.size;
|
||||
res.json({ status: "ok", partners: partners.length, online, listening });
|
||||
});
|
||||
|
||||
/**
|
||||
* Notifie un partenaire en attente qu'il a des messages
|
||||
*/
|
||||
function notifyWaitingPartner(partnerId) {
|
||||
if (waitingPartners.has(partnerId)) {
|
||||
const { res, heartbeat } = waitingPartners.get(partnerId);
|
||||
clearInterval(heartbeat);
|
||||
waitingPartners.delete(partnerId);
|
||||
|
||||
const messages = DB.getUndeliveredMessages(partnerId);
|
||||
for (const msg of messages) {
|
||||
DB.markDelivered(msg.id);
|
||||
}
|
||||
|
||||
res.json({ hasMessages: true, messages });
|
||||
}
|
||||
}
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`[BROKER] Claude Duo Broker v2 running on http://localhost:${PORT}`);
|
||||
console.log(`[BROKER] Database: data/duo.db`);
|
||||
console.log(`[BROKER] Claude Duo Broker v3 (Conversations) running on http://localhost:${PORT}`);
|
||||
});
|
||||
|
||||
110
docs/db-schema.md
Normal file
110
docs/db-schema.md
Normal file
@ -0,0 +1,110 @@
|
||||
# Structure de la Base de Données v3
|
||||
|
||||
La base SQLite est créée automatiquement dans `data/duo.db`.
|
||||
|
||||
## Tables
|
||||
|
||||
### partners
|
||||
|
||||
Stocke les informations sur les partenaires (instances Claude Code).
|
||||
|
||||
| Colonne | Type | Description |
|
||||
|---------|------|-------------|
|
||||
| `id` | TEXT (PK) | Identifiant unique (basé sur le nom du dossier projet) |
|
||||
| `name` | TEXT | Nom d'affichage du partenaire |
|
||||
| `project_path` | TEXT | Chemin absolu du projet (pour les notifications CLAUDE.md) |
|
||||
| `created_at` | DATETIME | Date de première inscription |
|
||||
| `last_seen` | DATETIME | Dernière activité |
|
||||
| `status` | TEXT | `online` ou `offline` |
|
||||
| `status_message` | TEXT | Message de status personnalisé |
|
||||
| `notifications_enabled` | INTEGER | 1 = activées, 0 = désactivées |
|
||||
|
||||
### conversations
|
||||
|
||||
Stocke les conversations (directes ou de groupe).
|
||||
|
||||
| Colonne | Type | Description |
|
||||
|---------|------|-------------|
|
||||
| `id` | TEXT (PK) | `direct_<a>_<b>` pour direct, `group_<ts>_<rand>` pour groupe |
|
||||
| `name` | TEXT | Nom de la conversation (null pour direct) |
|
||||
| `type` | TEXT | `direct` ou `group` |
|
||||
| `created_at` | DATETIME | Date de création |
|
||||
| `created_by` | TEXT (FK) | Créateur de la conversation |
|
||||
| `is_archived` | INTEGER | 1 = archivée (plus de participants) |
|
||||
|
||||
### conversation_participants
|
||||
|
||||
Lie les partenaires aux conversations.
|
||||
|
||||
| Colonne | Type | Description |
|
||||
|---------|------|-------------|
|
||||
| `conversation_id` | TEXT (PK) | Référence conversation |
|
||||
| `partner_id` | TEXT (PK) | Référence partenaire |
|
||||
| `joined_at` | DATETIME | Date d'arrivée |
|
||||
| `last_read_at` | DATETIME | Dernier message lu (pour calculer les non lus) |
|
||||
|
||||
### messages
|
||||
|
||||
Stocke tous les messages.
|
||||
|
||||
| Colonne | Type | Description |
|
||||
|---------|------|-------------|
|
||||
| `id` | INTEGER (PK) | Auto-increment |
|
||||
| `conversation_id` | TEXT (FK) | Conversation du message |
|
||||
| `from_id` | TEXT (FK) | Expéditeur |
|
||||
| `content` | TEXT | Contenu du message |
|
||||
| `created_at` | DATETIME | Date de création |
|
||||
|
||||
## Diagramme ER
|
||||
|
||||
```
|
||||
┌─────────────────────┐
|
||||
│ partners │
|
||||
├─────────────────────┤
|
||||
│ id (PK) │◄────────────────────────────┐
|
||||
│ name │ │
|
||||
│ project_path │ │
|
||||
│ status │ │
|
||||
│ status_message │ │
|
||||
│ notifications_enabled│ │
|
||||
│ created_at │ │
|
||||
│ last_seen │ │
|
||||
└─────────────────────┘ │
|
||||
│ │
|
||||
│ │
|
||||
▼ │
|
||||
┌─────────────────────────────┐ │
|
||||
│ conversation_participants │ │
|
||||
├─────────────────────────────┤ │
|
||||
│ conversation_id (PK, FK) │─────┐ │
|
||||
│ partner_id (PK, FK) │─────│───────────────┘
|
||||
│ joined_at │ │
|
||||
│ last_read_at │ │
|
||||
└─────────────────────────────┘ │
|
||||
│
|
||||
▼
|
||||
┌─────────────────────┐ ┌─────────────────────┐
|
||||
│ conversations │ │ messages │
|
||||
├─────────────────────┤ ├─────────────────────┤
|
||||
│ id (PK) │◄───│ conversation_id (FK)│
|
||||
│ name │ │ id (PK) │
|
||||
│ type │ │ from_id (FK) │───► partners.id
|
||||
│ created_at │ │ content │
|
||||
│ created_by (FK) │ │ created_at │
|
||||
│ is_archived │ └─────────────────────┘
|
||||
└─────────────────────┘
|
||||
```
|
||||
|
||||
## Conversations directes vs groupe
|
||||
|
||||
### Direct (1-to-1)
|
||||
- ID déterministe: `direct_alice_bob` (trié alphabétiquement)
|
||||
- Créée automatiquement au premier message
|
||||
- Impossible à quitter
|
||||
- Toujours 2 participants
|
||||
|
||||
### Groupe
|
||||
- ID aléatoire: `group_1706123456789_abc123def`
|
||||
- Créée explicitement via `create_conversation`
|
||||
- Possibilité de quitter
|
||||
- Auto-archivée quand plus de participants
|
||||
52
docs/schema.sql
Normal file
52
docs/schema.sql
Normal file
@ -0,0 +1,52 @@
|
||||
-- Schema de la base de données Claude Duo v3 (Conversations)
|
||||
-- La base est créée automatiquement par broker/db.js
|
||||
|
||||
-- Partenaires (instances Claude Code)
|
||||
CREATE TABLE IF NOT EXISTS partners (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
project_path TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
last_seen DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
status TEXT DEFAULT 'online',
|
||||
status_message TEXT,
|
||||
notifications_enabled INTEGER DEFAULT 1
|
||||
);
|
||||
|
||||
-- Conversations
|
||||
CREATE TABLE IF NOT EXISTS conversations (
|
||||
id TEXT PRIMARY KEY, -- direct_<a>_<b> ou group_<timestamp>_<random>
|
||||
name TEXT, -- Nom (null pour les direct)
|
||||
type TEXT NOT NULL DEFAULT 'direct', -- 'direct' ou 'group'
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by TEXT, -- Créateur (pour les groupes)
|
||||
is_archived INTEGER DEFAULT 0, -- Archivée quand plus de participants
|
||||
FOREIGN KEY (created_by) REFERENCES partners(id)
|
||||
);
|
||||
|
||||
-- Participants aux conversations
|
||||
CREATE TABLE IF NOT EXISTS conversation_participants (
|
||||
conversation_id TEXT NOT NULL,
|
||||
partner_id TEXT NOT NULL,
|
||||
joined_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
last_read_at DATETIME, -- Pour calculer les messages non lus
|
||||
PRIMARY KEY (conversation_id, partner_id),
|
||||
FOREIGN KEY (conversation_id) REFERENCES conversations(id),
|
||||
FOREIGN KEY (partner_id) REFERENCES partners(id)
|
||||
);
|
||||
|
||||
-- Messages
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
conversation_id TEXT NOT NULL,
|
||||
from_id TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (conversation_id) REFERENCES conversations(id),
|
||||
FOREIGN KEY (from_id) REFERENCES partners(id)
|
||||
);
|
||||
|
||||
-- Index
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_conversation ON messages(conversation_id, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_participants_partner ON conversation_participants(partner_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_conversations_archived ON conversations(is_archived);
|
||||
@ -7,60 +7,39 @@ import {
|
||||
ListToolsRequestSchema,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
|
||||
const BROKER_URL = process.env.BROKER_URL || "http://localhost:3210";
|
||||
const PARTNER_NAME = process.env.PARTNER_NAME || "Claude";
|
||||
import { brokerFetch, myId, PARTNER_NAME, cwd, setRegistered } from "./shared.js";
|
||||
|
||||
// ID basé sur le dossier de travail (unique par projet)
|
||||
const cwd = process.cwd();
|
||||
const projectName = cwd.split(/[/\\]/).pop().toLowerCase().replace(/[^a-z0-9]/g, "_");
|
||||
const myId = projectName || "partner";
|
||||
// Import all tools
|
||||
import * as register from "./tools/register.js";
|
||||
import * as talk from "./tools/talk.js";
|
||||
import * as listen from "./tools/listen.js";
|
||||
import * as listPartners from "./tools/list_partners.js";
|
||||
import * as listConversations from "./tools/list_conversations.js";
|
||||
import * as createConversation from "./tools/create_conversation.js";
|
||||
import * as leaveConversation from "./tools/leave_conversation.js";
|
||||
import * as history from "./tools/history.js";
|
||||
import * as setStatus from "./tools/set_status.js";
|
||||
import * as notifications from "./tools/notifications.js";
|
||||
|
||||
let isRegistered = false;
|
||||
let lastReceivedRequestId = null; // Pour savoir à quel message répondre
|
||||
// Tool registry
|
||||
const tools = {
|
||||
register,
|
||||
talk,
|
||||
listen,
|
||||
list_partners: listPartners,
|
||||
list_conversations: listConversations,
|
||||
create_conversation: createConversation,
|
||||
leave_conversation: leaveConversation,
|
||||
history,
|
||||
set_status: setStatus,
|
||||
notifications,
|
||||
};
|
||||
|
||||
/**
|
||||
* Appel HTTP au broker
|
||||
*/
|
||||
async function brokerFetch(path, options = {}, timeoutMs = 0) {
|
||||
const url = `${BROKER_URL}${path}`;
|
||||
|
||||
const fetchOptions = {
|
||||
...options,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...options.headers,
|
||||
},
|
||||
};
|
||||
|
||||
if (timeoutMs > 0) {
|
||||
const controller = new AbortController();
|
||||
setTimeout(() => controller.abort(), timeoutMs);
|
||||
fetchOptions.signal = controller.signal;
|
||||
}
|
||||
|
||||
const response = await fetch(url, fetchOptions);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* S'enregistrer auprès du broker
|
||||
*/
|
||||
async function ensureRegistered() {
|
||||
if (!isRegistered) {
|
||||
await brokerFetch("/register", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ partnerId: myId, name: PARTNER_NAME }),
|
||||
});
|
||||
isRegistered = true;
|
||||
console.error(`[MCP-PARTNER] Registered as ${PARTNER_NAME} (${myId})`);
|
||||
}
|
||||
}
|
||||
|
||||
// Créer le serveur MCP
|
||||
// Create MCP server
|
||||
const server = new Server(
|
||||
{
|
||||
name: "mcp-claude-duo-partner",
|
||||
version: "2.0.0",
|
||||
version: "3.0.0",
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
@ -69,414 +48,45 @@ const server = new Server(
|
||||
}
|
||||
);
|
||||
|
||||
// Liste des tools
|
||||
// List all tools
|
||||
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||
return {
|
||||
tools: [
|
||||
{
|
||||
name: "register",
|
||||
description:
|
||||
"S'enregistre auprès du réseau de conversation. Utilise au début pour te connecter.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: {
|
||||
type: "string",
|
||||
description: "Ton nom/pseudo (optionnel)",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "talk",
|
||||
description:
|
||||
"Envoie un message à un partenaire et attend sa réponse. Pour initier ou continuer une conversation.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
message: {
|
||||
type: "string",
|
||||
description: "Le message à envoyer",
|
||||
},
|
||||
to: {
|
||||
type: "string",
|
||||
description: "L'ID du destinataire (optionnel si un seul partenaire)",
|
||||
},
|
||||
},
|
||||
required: ["message"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "check_messages",
|
||||
description:
|
||||
"Vérifie s'il y a des messages en attente. Les messages sont bufferisés, donc pas besoin d'écouter en permanence.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
wait: {
|
||||
type: "boolean",
|
||||
description: "Si true, attend qu'un message arrive (long-polling). Sinon retourne immédiatement.",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "reply",
|
||||
description:
|
||||
"Répond au dernier message reçu. À utiliser après check_messages quand quelqu'un attend ta réponse.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
message: {
|
||||
type: "string",
|
||||
description: "Ta réponse",
|
||||
},
|
||||
},
|
||||
required: ["message"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "listen",
|
||||
description:
|
||||
"Écoute en temps réel les messages entrants (long-polling). Bloque jusqu'à ce qu'un message arrive.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "list_partners",
|
||||
description: "Liste tous les partenaires connectés au réseau.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "history",
|
||||
description: "Récupère l'historique de conversation avec un partenaire.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
partnerId: {
|
||||
type: "string",
|
||||
description: "L'ID du partenaire",
|
||||
},
|
||||
limit: {
|
||||
type: "number",
|
||||
description: "Nombre de messages max (défaut: 20)",
|
||||
},
|
||||
},
|
||||
required: ["partnerId"],
|
||||
},
|
||||
},
|
||||
],
|
||||
tools: Object.values(tools).map((t) => t.definition),
|
||||
};
|
||||
});
|
||||
|
||||
// Handler des tools
|
||||
// Handle tool calls
|
||||
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
const { name, arguments: args } = request.params;
|
||||
|
||||
switch (name) {
|
||||
case "register": {
|
||||
try {
|
||||
const displayName = args.name || PARTNER_NAME;
|
||||
await brokerFetch("/register", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ partnerId: myId, name: displayName }),
|
||||
});
|
||||
isRegistered = true;
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Connecté en tant que **${displayName}** (ID: ${myId})`,
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [{ type: "text", text: `Erreur: ${error.message}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
case "talk": {
|
||||
try {
|
||||
await ensureRegistered();
|
||||
|
||||
// Trouver le destinataire
|
||||
let toId = args.to;
|
||||
if (!toId) {
|
||||
const { partners } = await brokerFetch("/partners");
|
||||
const other = partners?.find((p) => p.id !== myId);
|
||||
if (!other) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Aucun partenaire connecté. Attends qu'un autre Claude se connecte.",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
toId = other.id;
|
||||
}
|
||||
|
||||
const response = await brokerFetch("/talk", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
fromId: myId,
|
||||
toId,
|
||||
content: args.message,
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.error) {
|
||||
return {
|
||||
content: [{ type: "text", text: `Erreur: ${response.error}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `**${toId}:** ${response.content}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [{ type: "text", text: `Erreur: ${error.message}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
case "check_messages": {
|
||||
try {
|
||||
await ensureRegistered();
|
||||
|
||||
let response;
|
||||
if (args.wait) {
|
||||
// Long-polling
|
||||
response = await brokerFetch(`/wait/${myId}`);
|
||||
} else {
|
||||
// Récupération immédiate
|
||||
response = await brokerFetch(`/messages/${myId}`);
|
||||
response = { messages: response.messages, hasMessages: response.messages?.length > 0 };
|
||||
}
|
||||
|
||||
if (!response.hasMessages || !response.messages?.length) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Pas de nouveaux messages.",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// Formater les messages
|
||||
let text = `**${response.messages.length} message(s) reçu(s):**\n\n`;
|
||||
for (const msg of response.messages) {
|
||||
text += `**${msg.from_id}:** ${msg.content}\n`;
|
||||
// Garder le request_id du dernier message pour pouvoir y répondre
|
||||
if (msg.request_id) {
|
||||
lastReceivedRequestId = msg.request_id;
|
||||
}
|
||||
}
|
||||
|
||||
if (lastReceivedRequestId) {
|
||||
text += `\n_Utilise \`reply\` pour répondre._`;
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text }],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [{ type: "text", text: `Erreur: ${error.message}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
case "reply": {
|
||||
try {
|
||||
await ensureRegistered();
|
||||
|
||||
if (!lastReceivedRequestId) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Aucun message en attente de réponse. Utilise `check_messages` d'abord.",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// Trouver le destinataire original
|
||||
const { partners } = await brokerFetch("/partners");
|
||||
const other = partners?.find((p) => p.id !== myId);
|
||||
const toId = other?.id || "unknown";
|
||||
|
||||
await brokerFetch("/respond", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
fromId: myId,
|
||||
toId,
|
||||
content: args.message,
|
||||
requestId: lastReceivedRequestId,
|
||||
}),
|
||||
});
|
||||
|
||||
lastReceivedRequestId = null;
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Réponse envoyée.",
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [{ type: "text", text: `Erreur: ${error.message}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
case "listen": {
|
||||
try {
|
||||
await ensureRegistered();
|
||||
|
||||
// Long-polling - attend qu'un message arrive
|
||||
console.error("[MCP-PARTNER] Listening...");
|
||||
const response = await brokerFetch(`/wait/${myId}`);
|
||||
|
||||
if (!response.hasMessages || !response.messages?.length) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Timeout. Rappelle `listen` pour continuer à écouter.",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// Formater les messages
|
||||
let text = "";
|
||||
for (const msg of response.messages) {
|
||||
text += `**${msg.from_id}:** ${msg.content}\n`;
|
||||
if (msg.request_id) {
|
||||
lastReceivedRequestId = msg.request_id;
|
||||
}
|
||||
}
|
||||
|
||||
if (lastReceivedRequestId) {
|
||||
text += `\n_Utilise \`reply\` pour répondre._`;
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text }],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [{ type: "text", text: `Erreur: ${error.message}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
case "list_partners": {
|
||||
try {
|
||||
const { partners } = await brokerFetch("/partners");
|
||||
|
||||
if (!partners?.length) {
|
||||
return {
|
||||
content: [{ type: "text", text: "Aucun partenaire enregistré." }],
|
||||
};
|
||||
}
|
||||
|
||||
let text = "**Partenaires:**\n\n";
|
||||
for (const p of partners) {
|
||||
const status = p.status === "online" ? "🟢" : "⚫";
|
||||
const isMe = p.id === myId ? " (toi)" : "";
|
||||
text += `${status} **${p.name}** (${p.id})${isMe}\n`;
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text }],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [{ type: "text", text: `Erreur: ${error.message}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
case "history": {
|
||||
try {
|
||||
const limit = args.limit || 20;
|
||||
const response = await brokerFetch(
|
||||
`/history/${myId}/${args.partnerId}?limit=${limit}`
|
||||
);
|
||||
|
||||
if (!response.messages?.length) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Pas d'historique avec ${args.partnerId}.`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
let text = `**Historique avec ${args.partnerId}:**\n\n`;
|
||||
// Inverser pour avoir l'ordre chronologique
|
||||
const messages = response.messages.reverse();
|
||||
for (const msg of messages) {
|
||||
const date = new Date(msg.created_at).toLocaleString();
|
||||
text += `[${date}] **${msg.from_id}:** ${msg.content}\n\n`;
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text }],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [{ type: "text", text: `Erreur: ${error.message}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
return {
|
||||
content: [{ type: "text", text: `Tool inconnu: ${name}` }],
|
||||
isError: true,
|
||||
};
|
||||
const tool = tools[name];
|
||||
if (!tool) {
|
||||
return {
|
||||
content: [{ type: "text", text: `Tool inconnu: ${name}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
return await tool.handler(args || {});
|
||||
});
|
||||
|
||||
// Démarrer
|
||||
// Start server
|
||||
async function main() {
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
console.error(`[MCP-PARTNER] Started (ID: ${myId})`);
|
||||
|
||||
// Auto-register on startup
|
||||
try {
|
||||
await brokerFetch("/register", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ partnerId: myId, name: PARTNER_NAME, projectPath: cwd }),
|
||||
});
|
||||
setRegistered(true);
|
||||
console.error(`[MCP-PARTNER] Auto-registered as ${PARTNER_NAME} (${myId})`);
|
||||
} catch (error) {
|
||||
console.error(`[MCP-PARTNER] Auto-register failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
|
||||
58
mcp-partner/shared.js
Normal file
58
mcp-partner/shared.js
Normal file
@ -0,0 +1,58 @@
|
||||
// Shared utilities and state for MCP partner
|
||||
|
||||
const BROKER_URL = process.env.BROKER_URL || "http://localhost:3210";
|
||||
const PARTNER_NAME = process.env.PARTNER_NAME || "Claude";
|
||||
|
||||
// ID basé sur le dossier de travail (unique par projet)
|
||||
const cwd = process.cwd();
|
||||
const projectName = cwd.split(/[/\\]/).pop().toLowerCase().replace(/[^a-z0-9]/g, "_");
|
||||
const myId = projectName || "partner";
|
||||
|
||||
let isRegistered = false;
|
||||
|
||||
/**
|
||||
* Appel HTTP au broker
|
||||
*/
|
||||
async function brokerFetch(path, options = {}) {
|
||||
const url = `${BROKER_URL}${path}`;
|
||||
|
||||
const fetchOptions = {
|
||||
...options,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...options.headers,
|
||||
},
|
||||
};
|
||||
|
||||
const response = await fetch(url, fetchOptions);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* S'enregistrer auprès du broker
|
||||
*/
|
||||
async function ensureRegistered() {
|
||||
if (!isRegistered) {
|
||||
await brokerFetch("/register", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ partnerId: myId, name: PARTNER_NAME, projectPath: cwd }),
|
||||
});
|
||||
isRegistered = true;
|
||||
console.error(`[MCP-PARTNER] Registered as ${PARTNER_NAME} (${myId}) at ${cwd}`);
|
||||
}
|
||||
}
|
||||
|
||||
function setRegistered(value) {
|
||||
isRegistered = value;
|
||||
}
|
||||
|
||||
export {
|
||||
BROKER_URL,
|
||||
PARTNER_NAME,
|
||||
cwd,
|
||||
myId,
|
||||
isRegistered,
|
||||
brokerFetch,
|
||||
ensureRegistered,
|
||||
setRegistered,
|
||||
};
|
||||
58
mcp-partner/tools/create_conversation.js
Normal file
58
mcp-partner/tools/create_conversation.js
Normal file
@ -0,0 +1,58 @@
|
||||
import { brokerFetch, myId, ensureRegistered } from "../shared.js";
|
||||
|
||||
export const definition = {
|
||||
name: "create_conversation",
|
||||
description: "Crée une nouvelle conversation de groupe.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: {
|
||||
type: "string",
|
||||
description: "Nom de la conversation",
|
||||
},
|
||||
participants: {
|
||||
type: "string",
|
||||
description: "IDs des participants séparés par des virgules",
|
||||
},
|
||||
},
|
||||
required: ["name", "participants"],
|
||||
},
|
||||
};
|
||||
|
||||
export async function handler(args) {
|
||||
try {
|
||||
await ensureRegistered();
|
||||
|
||||
const participantIds = args.participants.split(",").map((s) => s.trim());
|
||||
|
||||
const response = await brokerFetch("/conversations", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
creatorId: myId,
|
||||
name: args.name,
|
||||
participants: participantIds,
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.error) {
|
||||
return {
|
||||
content: [{ type: "text", text: `Erreur: ${response.error}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Conversation créée: **${args.name}**\nID: \`${response.conversation.id}\`\nParticipants: ${participantIds.join(", ")}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [{ type: "text", text: `Erreur: ${error.message}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
61
mcp-partner/tools/history.js
Normal file
61
mcp-partner/tools/history.js
Normal file
@ -0,0 +1,61 @@
|
||||
import { brokerFetch, ensureRegistered } from "../shared.js";
|
||||
|
||||
export const definition = {
|
||||
name: "history",
|
||||
description: "Récupère l'historique d'une conversation.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
conversation: {
|
||||
type: "string",
|
||||
description: "ID de la conversation",
|
||||
},
|
||||
limit: {
|
||||
type: "number",
|
||||
description: "Nombre de messages max (défaut: 50)",
|
||||
},
|
||||
},
|
||||
required: ["conversation"],
|
||||
},
|
||||
};
|
||||
|
||||
export async function handler(args) {
|
||||
try {
|
||||
await ensureRegistered();
|
||||
|
||||
const limit = args.limit || 50;
|
||||
const response = await brokerFetch(
|
||||
`/conversations/${args.conversation}/messages?limit=${limit}`
|
||||
);
|
||||
|
||||
if (response.error) {
|
||||
return {
|
||||
content: [{ type: "text", text: `Erreur: ${response.error}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (!response.messages?.length) {
|
||||
return {
|
||||
content: [{ type: "text", text: `Pas de messages dans cette conversation.` }],
|
||||
};
|
||||
}
|
||||
|
||||
const convName = response.conversation.name || response.conversation.id;
|
||||
let text = `**Historique: ${convName}**\n\n`;
|
||||
|
||||
for (const msg of response.messages) {
|
||||
const date = new Date(msg.created_at).toLocaleString();
|
||||
text += `[${date}] **${msg.from_id}:** ${msg.content}\n\n`;
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text }],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [{ type: "text", text: `Erreur: ${error.message}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
44
mcp-partner/tools/leave_conversation.js
Normal file
44
mcp-partner/tools/leave_conversation.js
Normal file
@ -0,0 +1,44 @@
|
||||
import { brokerFetch, myId, ensureRegistered } from "../shared.js";
|
||||
|
||||
export const definition = {
|
||||
name: "leave_conversation",
|
||||
description: "Quitte une conversation de groupe. Impossible de quitter une conv directe.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
conversation: {
|
||||
type: "string",
|
||||
description: "ID de la conversation à quitter",
|
||||
},
|
||||
},
|
||||
required: ["conversation"],
|
||||
},
|
||||
};
|
||||
|
||||
export async function handler(args) {
|
||||
try {
|
||||
await ensureRegistered();
|
||||
|
||||
const response = await brokerFetch(`/conversations/${args.conversation}/leave`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ partnerId: myId }),
|
||||
});
|
||||
|
||||
if (response.error) {
|
||||
return {
|
||||
content: [{ type: "text", text: `Erreur: ${response.error}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
const archived = response.archived ? " (conversation archivée car plus de participants)" : "";
|
||||
return {
|
||||
content: [{ type: "text", text: `Tu as quitté la conversation.${archived}` }],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [{ type: "text", text: `Erreur: ${error.message}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
42
mcp-partner/tools/list_conversations.js
Normal file
42
mcp-partner/tools/list_conversations.js
Normal file
@ -0,0 +1,42 @@
|
||||
import { brokerFetch, myId, ensureRegistered } from "../shared.js";
|
||||
|
||||
export const definition = {
|
||||
name: "list_conversations",
|
||||
description: "Liste toutes tes conversations actives.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
},
|
||||
};
|
||||
|
||||
export async function handler() {
|
||||
try {
|
||||
await ensureRegistered();
|
||||
|
||||
const { conversations } = await brokerFetch(`/conversations/${myId}`);
|
||||
|
||||
if (!conversations?.length) {
|
||||
return {
|
||||
content: [{ type: "text", text: "Aucune conversation." }],
|
||||
};
|
||||
}
|
||||
|
||||
let text = "**Conversations:**\n\n";
|
||||
for (const conv of conversations) {
|
||||
const type = conv.type === "direct" ? "💬" : "👥";
|
||||
const unread = conv.unread_count > 0 ? ` (${conv.unread_count} non lu${conv.unread_count > 1 ? "s" : ""})` : "";
|
||||
const participants = conv.participants.map((p) => p.name).join(", ");
|
||||
const name = conv.name || participants;
|
||||
text += `${type} **${name}**${unread}\n ID: \`${conv.id}\`\n Participants: ${participants}\n\n`;
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text }],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [{ type: "text", text: `Erreur: ${error.message}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
42
mcp-partner/tools/list_partners.js
Normal file
42
mcp-partner/tools/list_partners.js
Normal file
@ -0,0 +1,42 @@
|
||||
import { brokerFetch, myId } from "../shared.js";
|
||||
|
||||
export const definition = {
|
||||
name: "list_partners",
|
||||
description: "Liste tous les partenaires connectés au réseau.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
},
|
||||
};
|
||||
|
||||
export async function handler() {
|
||||
try {
|
||||
const { partners } = await brokerFetch("/partners");
|
||||
|
||||
if (!partners?.length) {
|
||||
return {
|
||||
content: [{ type: "text", text: "Aucun partenaire enregistré." }],
|
||||
};
|
||||
}
|
||||
|
||||
let text = "**Partenaires:**\n\n";
|
||||
for (const p of partners) {
|
||||
const status = p.status === "online" ? "🟢" : "⚫";
|
||||
const listening = p.isListening ? " 👂" : "";
|
||||
const isMe = p.id === myId ? " (toi)" : "";
|
||||
const statusMsg = p.status_message ? ` — _${p.status_message}_` : "";
|
||||
text += `${status}${listening} **${p.name}** (${p.id})${isMe}${statusMsg}\n`;
|
||||
}
|
||||
|
||||
text += "\n_Légende: 🟢 en ligne, ⚫ hors ligne, 👂 en écoute_";
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text }],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [{ type: "text", text: `Erreur: ${error.message}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
82
mcp-partner/tools/listen.js
Normal file
82
mcp-partner/tools/listen.js
Normal file
@ -0,0 +1,82 @@
|
||||
import { brokerFetch, myId, ensureRegistered } from "../shared.js";
|
||||
|
||||
export const definition = {
|
||||
name: "listen",
|
||||
description: "Écoute les messages entrants. Retourne immédiatement s'il y a des messages non lus, sinon attend.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
conversation: {
|
||||
type: "string",
|
||||
description: "ID de la conversation à écouter (optionnel, toutes par défaut)",
|
||||
},
|
||||
timeout: {
|
||||
type: "number",
|
||||
description: "Timeout en minutes (min: 2, max: 15, défaut: 2)",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export async function handler(args) {
|
||||
try {
|
||||
await ensureRegistered();
|
||||
|
||||
let timeoutMinutes = args.timeout || 2;
|
||||
timeoutMinutes = Math.max(2, Math.min(15, timeoutMinutes));
|
||||
|
||||
let url = `/listen/${myId}?timeout=${timeoutMinutes}`;
|
||||
if (args.conversation) {
|
||||
url += `&conversationId=${encodeURIComponent(args.conversation)}`;
|
||||
}
|
||||
|
||||
console.error(`[MCP-PARTNER] Listening (timeout: ${timeoutMinutes}min)...`);
|
||||
const response = await brokerFetch(url);
|
||||
|
||||
if (response.error) {
|
||||
return {
|
||||
content: [{ type: "text", text: `Erreur: ${response.error}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (!response.hasMessages || !response.messages?.length) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Timeout après ${response.timeoutMinutes || timeoutMinutes} minutes. Rappelle \`listen\` pour continuer.`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// Grouper par conversation
|
||||
const byConv = {};
|
||||
for (const msg of response.messages) {
|
||||
if (!byConv[msg.conversation_id]) {
|
||||
byConv[msg.conversation_id] = [];
|
||||
}
|
||||
byConv[msg.conversation_id].push(msg);
|
||||
}
|
||||
|
||||
let text = `**${response.messages.length} message(s) reçu(s):**\n\n`;
|
||||
for (const [convId, msgs] of Object.entries(byConv)) {
|
||||
text += `📁 **${convId}**\n`;
|
||||
for (const msg of msgs) {
|
||||
const time = new Date(msg.created_at).toLocaleTimeString();
|
||||
text += ` [${time}] **${msg.from_id}:** ${msg.content}\n`;
|
||||
}
|
||||
text += "\n";
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text }],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [{ type: "text", text: `Erreur: ${error.message}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
37
mcp-partner/tools/notifications.js
Normal file
37
mcp-partner/tools/notifications.js
Normal file
@ -0,0 +1,37 @@
|
||||
import { brokerFetch, myId, ensureRegistered } from "../shared.js";
|
||||
|
||||
export const definition = {
|
||||
name: "notifications",
|
||||
description: "Active ou désactive les notifications dans CLAUDE.md.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
enabled: {
|
||||
type: "boolean",
|
||||
description: "true pour activer, false pour désactiver",
|
||||
},
|
||||
},
|
||||
required: ["enabled"],
|
||||
},
|
||||
};
|
||||
|
||||
export async function handler(args) {
|
||||
try {
|
||||
await ensureRegistered();
|
||||
|
||||
await brokerFetch(`/partners/${myId}/notifications`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ enabled: args.enabled }),
|
||||
});
|
||||
|
||||
const status = args.enabled ? "activées" : "désactivées";
|
||||
return {
|
||||
content: [{ type: "text", text: `Notifications ${status}.` }],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [{ type: "text", text: `Erreur: ${error.message}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
40
mcp-partner/tools/register.js
Normal file
40
mcp-partner/tools/register.js
Normal file
@ -0,0 +1,40 @@
|
||||
import { brokerFetch, myId, cwd, PARTNER_NAME, setRegistered } from "../shared.js";
|
||||
|
||||
export const definition = {
|
||||
name: "register",
|
||||
description: "S'enregistre auprès du réseau de conversation. Optionnel car auto-register au démarrage.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: {
|
||||
type: "string",
|
||||
description: "Ton nom/pseudo (optionnel)",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export async function handler(args) {
|
||||
try {
|
||||
const displayName = args.name || PARTNER_NAME;
|
||||
await brokerFetch("/register", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ partnerId: myId, name: displayName, projectPath: cwd }),
|
||||
});
|
||||
setRegistered(true);
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Connecté en tant que **${displayName}** (ID: ${myId})\nProjet: ${cwd}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [{ type: "text", text: `Erreur: ${error.message}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
41
mcp-partner/tools/set_status.js
Normal file
41
mcp-partner/tools/set_status.js
Normal file
@ -0,0 +1,41 @@
|
||||
import { brokerFetch, myId, ensureRegistered } from "../shared.js";
|
||||
|
||||
export const definition = {
|
||||
name: "set_status",
|
||||
description: "Définit ton status visible par les autres partenaires.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
message: {
|
||||
type: "string",
|
||||
description: "Ton status (ex: 'Working on auth module'). Vide pour effacer.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export async function handler(args) {
|
||||
try {
|
||||
await ensureRegistered();
|
||||
|
||||
await brokerFetch(`/partners/${myId}/status`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ message: args.message || null }),
|
||||
});
|
||||
|
||||
if (args.message) {
|
||||
return {
|
||||
content: [{ type: "text", text: `Status: _${args.message}_` }],
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
content: [{ type: "text", text: "Status effacé." }],
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [{ type: "text", text: `Erreur: ${error.message}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
90
mcp-partner/tools/talk.js
Normal file
90
mcp-partner/tools/talk.js
Normal file
@ -0,0 +1,90 @@
|
||||
import { brokerFetch, myId, ensureRegistered } from "../shared.js";
|
||||
|
||||
export const definition = {
|
||||
name: "talk",
|
||||
description: "Envoie un message dans une conversation. Crée automatiquement une conv directe si besoin.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
message: {
|
||||
type: "string",
|
||||
description: "Le message à envoyer",
|
||||
},
|
||||
to: {
|
||||
type: "string",
|
||||
description: "L'ID du destinataire (pour conv directe)",
|
||||
},
|
||||
conversation: {
|
||||
type: "string",
|
||||
description: "L'ID de la conversation (pour conv existante)",
|
||||
},
|
||||
},
|
||||
required: ["message"],
|
||||
},
|
||||
};
|
||||
|
||||
export async function handler(args) {
|
||||
try {
|
||||
await ensureRegistered();
|
||||
|
||||
if (!args.to && !args.conversation) {
|
||||
// Essayer de trouver un partenaire unique
|
||||
const { partners } = await brokerFetch("/partners");
|
||||
const others = partners?.filter((p) => p.id !== myId);
|
||||
if (!others?.length) {
|
||||
return {
|
||||
content: [{ type: "text", text: "Aucun partenaire enregistré. Précise `to` ou `conversation`." }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
if (others.length === 1) {
|
||||
args.to = others[0].id;
|
||||
} else {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Plusieurs partenaires: ${others.map((p) => p.id).join(", ")}. Précise \`to\` ou \`conversation\`.`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const response = await brokerFetch("/talk", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
fromId: myId,
|
||||
to: args.to,
|
||||
conversationId: args.conversation,
|
||||
content: args.message,
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.error) {
|
||||
return {
|
||||
content: [{ type: "text", text: `Erreur: ${response.error}\n${response.message || ""}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
const status = response.notified > 0
|
||||
? `${response.notified} notifié(s) en temps réel`
|
||||
: `${response.queued} en file d'attente`;
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Message envoyé dans ${response.conversationId}\n${status}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [{ type: "text", text: `Erreur: ${error.message}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user