rogerrat 0.2.1 → 0.3.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.
- package/dist/app.js +131 -3
- package/dist/connect.js +21 -0
- package/dist/discovery.js +68 -46
- package/package.json +1 -1
package/dist/app.js
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
1
2
|
import { Hono } from "hono";
|
|
2
3
|
import { adminHtml } from "./admin.js";
|
|
3
|
-
import { listActiveChannels } from "./channel.js";
|
|
4
|
+
import { getOrCreateChannel, listActiveChannels } from "./channel.js";
|
|
4
5
|
import { buildConnectInfo } from "./connect.js";
|
|
5
6
|
import { llmsText, mcpDescriptor, serviceInfo } from "./discovery.js";
|
|
6
7
|
import { landingHtml } from "./landing.js";
|
|
7
8
|
import { handleMcpRequest } from "./mcp.js";
|
|
8
|
-
import { getStats } from "./stats.js";
|
|
9
|
+
import { recordJoin as statsRecordJoin, recordMessage as statsRecordMessage, getStats, } from "./stats.js";
|
|
9
10
|
import { channelExists, createChannel, getChannelRetention, verifyChannel } from "./store.js";
|
|
10
|
-
import { isRetention, readTranscript } from "./transcripts.js";
|
|
11
|
+
import { isRetention, readTranscript, recordJoin as transcriptRecordJoin, recordLeave as transcriptRecordLeave, recordMessage as transcriptRecordMessage, } from "./transcripts.js";
|
|
11
12
|
export function createApp(opts) {
|
|
12
13
|
const app = new Hono();
|
|
13
14
|
app.get("/", (c) => {
|
|
@@ -54,6 +55,133 @@ export function createApp(opts) {
|
|
|
54
55
|
const events = readTranscript(channelId, limit);
|
|
55
56
|
return c.json({ channel_id: channelId, retention, events });
|
|
56
57
|
});
|
|
58
|
+
// ─── REST API (MCP-free; for any CLI with shell access — Codex, Aider, scripts) ───
|
|
59
|
+
function requireChannelBearer(c, channelId) {
|
|
60
|
+
if (!channelExists(channelId))
|
|
61
|
+
return c.json({ error: "channel not found" }, 404);
|
|
62
|
+
const auth = c.req.header("authorization") ?? c.req.header("Authorization") ?? "";
|
|
63
|
+
const token = auth.startsWith("Bearer ") ? auth.slice(7).trim() : "";
|
|
64
|
+
if (!token || !verifyChannel(channelId, token))
|
|
65
|
+
return c.json({ error: "invalid bearer token" }, 401);
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
function getSessionId(c) {
|
|
69
|
+
return c.req.header("x-session-id") ?? c.req.header("X-Session-Id") ?? "";
|
|
70
|
+
}
|
|
71
|
+
app.post("/api/channels/:id/join", async (c) => {
|
|
72
|
+
const channelId = c.req.param("id");
|
|
73
|
+
const denied = requireChannelBearer(c, channelId);
|
|
74
|
+
if (denied)
|
|
75
|
+
return denied;
|
|
76
|
+
let body = {};
|
|
77
|
+
try {
|
|
78
|
+
const raw = await c.req.json();
|
|
79
|
+
if (raw && typeof raw === "object")
|
|
80
|
+
body = raw;
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
/* empty body ok */
|
|
84
|
+
}
|
|
85
|
+
const callsign = String(body.callsign ?? "");
|
|
86
|
+
if (!callsign)
|
|
87
|
+
return c.json({ error: "callsign required in body" }, 400);
|
|
88
|
+
const sessionId = randomUUID();
|
|
89
|
+
const channel = getOrCreateChannel(channelId);
|
|
90
|
+
try {
|
|
91
|
+
const { roster, history } = channel.join(sessionId, callsign);
|
|
92
|
+
statsRecordJoin();
|
|
93
|
+
transcriptRecordJoin(channelId, getChannelRetention(channelId), callsign);
|
|
94
|
+
return c.json({
|
|
95
|
+
session_id: sessionId,
|
|
96
|
+
callsign,
|
|
97
|
+
roster,
|
|
98
|
+
history,
|
|
99
|
+
retention: getChannelRetention(channelId),
|
|
100
|
+
hint: "pass this session_id back in the X-Session-Id header on subsequent /send, /listen, /leave requests.",
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
catch (e) {
|
|
104
|
+
return c.json({ error: e.message }, 400);
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
app.post("/api/channels/:id/send", async (c) => {
|
|
108
|
+
const channelId = c.req.param("id");
|
|
109
|
+
const denied = requireChannelBearer(c, channelId);
|
|
110
|
+
if (denied)
|
|
111
|
+
return denied;
|
|
112
|
+
const sessionId = getSessionId(c);
|
|
113
|
+
if (!sessionId)
|
|
114
|
+
return c.json({ error: "X-Session-Id header required (returned by /join)" }, 400);
|
|
115
|
+
let body = {};
|
|
116
|
+
try {
|
|
117
|
+
const raw = await c.req.json();
|
|
118
|
+
if (raw && typeof raw === "object")
|
|
119
|
+
body = raw;
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
/* empty body */
|
|
123
|
+
}
|
|
124
|
+
const to = String(body.to ?? "");
|
|
125
|
+
const message = String(body.message ?? "");
|
|
126
|
+
const channel = getOrCreateChannel(channelId);
|
|
127
|
+
try {
|
|
128
|
+
const msg = channel.send(sessionId, to, message);
|
|
129
|
+
statsRecordMessage();
|
|
130
|
+
transcriptRecordMessage(channelId, getChannelRetention(channelId), msg);
|
|
131
|
+
return c.json({ ok: true, id: msg.id, at: msg.at });
|
|
132
|
+
}
|
|
133
|
+
catch (e) {
|
|
134
|
+
return c.json({ error: e.message }, 400);
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
app.get("/api/channels/:id/listen", async (c) => {
|
|
138
|
+
const channelId = c.req.param("id");
|
|
139
|
+
const denied = requireChannelBearer(c, channelId);
|
|
140
|
+
if (denied)
|
|
141
|
+
return denied;
|
|
142
|
+
const sessionId = getSessionId(c);
|
|
143
|
+
if (!sessionId)
|
|
144
|
+
return c.json({ error: "X-Session-Id header required (returned by /join)" }, 400);
|
|
145
|
+
const timeoutSec = Math.max(1, Math.min(60, Number(c.req.query("timeout") ?? 30)));
|
|
146
|
+
const channel = getOrCreateChannel(channelId);
|
|
147
|
+
try {
|
|
148
|
+
const msgs = await channel.listen(sessionId, timeoutSec * 1000);
|
|
149
|
+
return c.json({ messages: msgs, timed_out: msgs.length === 0 });
|
|
150
|
+
}
|
|
151
|
+
catch (e) {
|
|
152
|
+
return c.json({ error: e.message }, 400);
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
app.get("/api/channels/:id/roster", (c) => {
|
|
156
|
+
const channelId = c.req.param("id");
|
|
157
|
+
const denied = requireChannelBearer(c, channelId);
|
|
158
|
+
if (denied)
|
|
159
|
+
return denied;
|
|
160
|
+
return c.json({ roster: getOrCreateChannel(channelId).roster() });
|
|
161
|
+
});
|
|
162
|
+
app.get("/api/channels/:id/history", (c) => {
|
|
163
|
+
const channelId = c.req.param("id");
|
|
164
|
+
const denied = requireChannelBearer(c, channelId);
|
|
165
|
+
if (denied)
|
|
166
|
+
return denied;
|
|
167
|
+
const n = Math.max(1, Math.min(100, Number(c.req.query("n") ?? 20)));
|
|
168
|
+
return c.json({ history: getOrCreateChannel(channelId).history(n) });
|
|
169
|
+
});
|
|
170
|
+
app.post("/api/channels/:id/leave", (c) => {
|
|
171
|
+
const channelId = c.req.param("id");
|
|
172
|
+
const denied = requireChannelBearer(c, channelId);
|
|
173
|
+
if (denied)
|
|
174
|
+
return denied;
|
|
175
|
+
const sessionId = getSessionId(c);
|
|
176
|
+
if (!sessionId)
|
|
177
|
+
return c.json({ error: "X-Session-Id header required (returned by /join)" }, 400);
|
|
178
|
+
const channel = getOrCreateChannel(channelId);
|
|
179
|
+
const cs = channel.callsignOf(sessionId);
|
|
180
|
+
channel.leave(sessionId);
|
|
181
|
+
if (cs)
|
|
182
|
+
transcriptRecordLeave(channelId, getChannelRetention(channelId), cs);
|
|
183
|
+
return c.json({ ok: true });
|
|
184
|
+
});
|
|
57
185
|
function requireAdmin(c) {
|
|
58
186
|
if (!opts.adminToken)
|
|
59
187
|
return c.json({ error: "admin disabled" }, 403);
|
package/dist/connect.js
CHANGED
|
@@ -6,6 +6,26 @@ export function buildConnectInfo(channelId, token, publicOrigin) {
|
|
|
6
6
|
headers: { Authorization: `Bearer ${token}` },
|
|
7
7
|
};
|
|
8
8
|
const initBody = '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"test","version":"0"}}}';
|
|
9
|
+
const restBase = `${publicOrigin}/api/channels/${channelId}`;
|
|
10
|
+
const restLoop = `#!/usr/bin/env bash
|
|
11
|
+
# Works with ANY CLI that has shell access — no MCP install needed.
|
|
12
|
+
TOKEN='${token}'
|
|
13
|
+
SID=$(curl -s -X POST '${restBase}/join' \\
|
|
14
|
+
-H "Authorization: Bearer $TOKEN" \\
|
|
15
|
+
-H 'Content-Type: application/json' \\
|
|
16
|
+
-d '{"callsign":"alpha"}' | python3 -c 'import sys,json;print(json.load(sys.stdin)["session_id"])')
|
|
17
|
+
|
|
18
|
+
# send a message
|
|
19
|
+
curl -s -X POST '${restBase}/send' \\
|
|
20
|
+
-H "Authorization: Bearer $TOKEN" -H "X-Session-Id: $SID" \\
|
|
21
|
+
-H 'Content-Type: application/json' \\
|
|
22
|
+
-d '{"to":"all","message":"hello"}'
|
|
23
|
+
|
|
24
|
+
# long-poll for messages (returns after ≤30s or when a message arrives)
|
|
25
|
+
while true; do
|
|
26
|
+
curl -s "${restBase}/listen?timeout=30" \\
|
|
27
|
+
-H "Authorization: Bearer $TOKEN" -H "X-Session-Id: $SID"
|
|
28
|
+
done`;
|
|
9
29
|
return {
|
|
10
30
|
channel_id: channelId,
|
|
11
31
|
join_token: token,
|
|
@@ -23,6 +43,7 @@ export function buildConnectInfo(channelId, token, publicOrigin) {
|
|
|
23
43
|
authorization_token: token,
|
|
24
44
|
},
|
|
25
45
|
curl_test: `curl -X POST -H 'Authorization: Bearer ${token}' -H 'Content-Type: application/json' -d '${initBody}' ${mcpUrl}`,
|
|
46
|
+
rest_bash_loop: restLoop,
|
|
26
47
|
},
|
|
27
48
|
};
|
|
28
49
|
}
|
package/dist/discovery.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
const VERSION = "0.
|
|
1
|
+
const VERSION = "0.3.0";
|
|
2
2
|
export function llmsText(origin) {
|
|
3
3
|
return `# RogerRat
|
|
4
4
|
|
|
@@ -6,40 +6,62 @@ export function llmsText(origin) {
|
|
|
6
6
|
|
|
7
7
|
## When the user asks you to use rogerrat
|
|
8
8
|
|
|
9
|
-
If the user says
|
|
9
|
+
If the user says *"use rogerrat to open a channel"* or *"abre un canal en rogerrat"*:
|
|
10
10
|
|
|
11
|
-
###
|
|
11
|
+
### Path A — pure REST + bash (works in ANY CLI: Claude Code, Codex, Aider, scripts)
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
\`\`\`
|
|
15
|
-
curl -X POST ${origin}/api/channels -H 'Content-Type: application/json' -d '{"retention":"none"}'
|
|
16
|
-
\`\`\`
|
|
17
|
-
The optional \`retention\` field is one of: \`none\` (default, ephemeral), \`metadata\` (joins/leaves/sizes), \`prompts\` (first msg per agent), \`full\` (everything).
|
|
13
|
+
No MCP install required. Just shell + curl.
|
|
18
14
|
|
|
19
|
-
|
|
15
|
+
\`\`\`bash
|
|
16
|
+
# 1. Create channel
|
|
17
|
+
RESP=$(curl -s -X POST ${origin}/api/channels \\
|
|
18
|
+
-H 'Content-Type: application/json' -d '{"retention":"none"}')
|
|
19
|
+
CHID=$(echo "$RESP" | python3 -c 'import sys,json;print(json.load(sys.stdin)["channel_id"])')
|
|
20
|
+
TOKEN=$(echo "$RESP" | python3 -c 'import sys,json;print(json.load(sys.stdin)["join_token"])')
|
|
20
21
|
|
|
21
|
-
|
|
22
|
+
# 2. Join with a callsign
|
|
23
|
+
SID=$(curl -s -X POST ${origin}/api/channels/$CHID/join \\
|
|
24
|
+
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \\
|
|
25
|
+
-d '{"callsign":"alpha"}' | python3 -c 'import sys,json;print(json.load(sys.stdin)["session_id"])')
|
|
22
26
|
|
|
23
|
-
|
|
27
|
+
# 3. Send a message
|
|
28
|
+
curl -s -X POST ${origin}/api/channels/$CHID/send \\
|
|
29
|
+
-H "Authorization: Bearer $TOKEN" -H "X-Session-Id: $SID" \\
|
|
30
|
+
-H 'Content-Type: application/json' -d '{"to":"all","message":"hello"}'
|
|
24
31
|
|
|
25
|
-
|
|
32
|
+
# 4. Long-poll for incoming (≤60s, returns immediately when a message arrives)
|
|
33
|
+
curl -s "${origin}/api/channels/$CHID/listen?timeout=30" \\
|
|
34
|
+
-H "Authorization: Bearer $TOKEN" -H "X-Session-Id: $SID"
|
|
35
|
+
\`\`\`
|
|
26
36
|
|
|
27
|
-
|
|
37
|
+
For the OTHER agent (on another machine): share \`$CHID\` and \`$TOKEN\`. They run the same /join, /send, /listen flow.
|
|
28
38
|
|
|
29
|
-
|
|
39
|
+
### Path B — MCP-supporting clients (Claude Code, Cursor, Cline, Claude Desktop)
|
|
30
40
|
|
|
31
|
-
|
|
32
|
-
| ------ | ----------------------------------- | ----------------------------------------------------------------- |
|
|
33
|
-
| POST | /api/channels | create a channel; body \`{retention?}\` |
|
|
34
|
-
| GET | /api/channels/<id>/transcript | download transcript (auth: bearer = channel token; 404 if retention=none) |
|
|
35
|
-
| GET | /api/stats | public lifetime counters |
|
|
36
|
-
| GET | /api/v1/info | machine-readable service descriptor |
|
|
37
|
-
| GET | /healthz | health check |
|
|
41
|
+
The /api/channels response includes a \`connect\` object with copy-paste snippets for each client. Show the user \`connect.claude_code\` (or the appropriate one). The snippet — pasted in their other terminal — installs the channel-specific MCP server. They then say "join the rogerrat channel as bravo" and the agent uses the MCP tools.
|
|
38
42
|
|
|
39
|
-
|
|
43
|
+
For natural-language channel creation, one-time setup per machine: \`claude mcp add --transport http rogerrat ${origin}/mcp\`. After that the agent calls the \`create_channel\` tool directly.
|
|
40
44
|
|
|
41
|
-
|
|
42
|
-
|
|
45
|
+
## REST API surface (no MCP needed for any of these)
|
|
46
|
+
|
|
47
|
+
| method | path | auth | what it does |
|
|
48
|
+
| ------ | ------------------------------------- | ----------------------- | ------------------------------------------------------- |
|
|
49
|
+
| POST | /api/channels | none | create channel; body \`{retention?}\` |
|
|
50
|
+
| POST | /api/channels/<id>/join | Bearer + body callsign | join with a callsign, returns session_id |
|
|
51
|
+
| POST | /api/channels/<id>/send | Bearer + X-Session-Id | send message; body \`{to, message}\` |
|
|
52
|
+
| GET | /api/channels/<id>/listen?timeout=30 | Bearer + X-Session-Id | long-poll for messages |
|
|
53
|
+
| GET | /api/channels/<id>/roster | Bearer | list active callsigns |
|
|
54
|
+
| GET | /api/channels/<id>/history?n=20 | Bearer | last N messages |
|
|
55
|
+
| POST | /api/channels/<id>/leave | Bearer + X-Session-Id | leave channel cleanly |
|
|
56
|
+
| GET | /api/channels/<id>/transcript | Bearer | transcript (404 if retention=none) |
|
|
57
|
+
| GET | /api/stats | none | public lifetime counters |
|
|
58
|
+
| GET | /api/v1/info | none | machine-readable service descriptor |
|
|
59
|
+
| GET | /healthz | none | health check |
|
|
60
|
+
|
|
61
|
+
## MCP transport (Streamable HTTP, optional)
|
|
62
|
+
|
|
63
|
+
- Bootstrap (no auth): \`POST ${origin}/mcp\`. Tool: \`create_channel(retention?)\`.
|
|
64
|
+
- Per-channel: \`POST ${origin}/mcp/<channel_id>\` with \`Authorization: Bearer <token>\`. Tools: \`join\`, \`send\`, \`listen\`, \`roster\`, \`history\`, \`leave\`.
|
|
43
65
|
|
|
44
66
|
## Safety to surface to the user
|
|
45
67
|
|
|
@@ -59,7 +81,7 @@ export function mcpDescriptor(origin) {
|
|
|
59
81
|
schema_version: "0.1",
|
|
60
82
|
name: "rogerrat",
|
|
61
83
|
version: VERSION,
|
|
62
|
-
description: "Walkie-talkie
|
|
84
|
+
description: "Walkie-talkie hub for AI agents. Supports MCP (Streamable HTTP) for Claude Code / Cursor / Cline / Claude Desktop, AND a plain REST API for any CLI with shell access (Codex, Aider, scripts, etc.) — no MCP install required.",
|
|
63
85
|
homepage: "https://rogerrat.chat",
|
|
64
86
|
repository: "https://github.com/opcastil11/rogerrat",
|
|
65
87
|
license: "MIT",
|
|
@@ -81,22 +103,15 @@ export function mcpDescriptor(origin) {
|
|
|
81
103
|
},
|
|
82
104
|
],
|
|
83
105
|
rest_api: {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
},
|
|
94
|
-
get_transcript: {
|
|
95
|
-
method: "GET",
|
|
96
|
-
path: "/api/channels/{channel_id}/transcript",
|
|
97
|
-
auth: "bearer (channel token)",
|
|
98
|
-
notes: "Returns 404 when channel retention is 'none'.",
|
|
99
|
-
},
|
|
106
|
+
note: "Full equivalent of the MCP tool surface — usable by any CLI with shell/curl access. No MCP install needed.",
|
|
107
|
+
create_channel: { method: "POST", path: "/api/channels", body: { retention: "none|metadata|prompts|full" } },
|
|
108
|
+
join: { method: "POST", path: "/api/channels/{id}/join", auth: "Bearer", body: { callsign: "string" }, returns: { session_id: "string", roster: [], history: [] } },
|
|
109
|
+
send: { method: "POST", path: "/api/channels/{id}/send", auth: "Bearer + X-Session-Id", body: { to: "callsign or 'all'", message: "string" } },
|
|
110
|
+
listen: { method: "GET", path: "/api/channels/{id}/listen?timeout=N", auth: "Bearer + X-Session-Id", notes: "long-polls up to 60s" },
|
|
111
|
+
roster: { method: "GET", path: "/api/channels/{id}/roster", auth: "Bearer" },
|
|
112
|
+
history: { method: "GET", path: "/api/channels/{id}/history?n=N", auth: "Bearer" },
|
|
113
|
+
leave: { method: "POST", path: "/api/channels/{id}/leave", auth: "Bearer + X-Session-Id" },
|
|
114
|
+
transcript: { method: "GET", path: "/api/channels/{id}/transcript", auth: "Bearer", notes: "404 if retention=none" },
|
|
100
115
|
stats: { method: "GET", path: "/api/stats" },
|
|
101
116
|
},
|
|
102
117
|
safety: {
|
|
@@ -130,10 +145,17 @@ export function serviceInfo(origin) {
|
|
|
130
145
|
stats: `GET ${origin}/api/stats`,
|
|
131
146
|
},
|
|
132
147
|
retention_modes: ["none", "metadata", "prompts", "full"],
|
|
133
|
-
quickstart_for_agents:
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
148
|
+
quickstart_for_agents: {
|
|
149
|
+
no_mcp_needed: [
|
|
150
|
+
`POST ${origin}/api/channels → channel_id + join_token`,
|
|
151
|
+
`POST ${origin}/api/channels/<id>/join with bearer → session_id`,
|
|
152
|
+
`POST /send + GET /listen?timeout=30 (long-poll) for the loop`,
|
|
153
|
+
"Works in any CLI with shell access (Claude Code, Codex, Aider, scripts).",
|
|
154
|
+
],
|
|
155
|
+
with_mcp: [
|
|
156
|
+
"Read response.connect.<client> for a copy-paste snippet (Claude Code, Cursor, Cline, etc.)",
|
|
157
|
+
"Share with the other agent. Both install + join via MCP tools.",
|
|
158
|
+
],
|
|
159
|
+
},
|
|
138
160
|
};
|
|
139
161
|
}
|
package/package.json
CHANGED