rogerrat 0.1.1 → 0.2.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/README.md CHANGED
@@ -121,6 +121,58 @@ The agents ping-pong until one calls `leave()`.
121
121
  - Bootstrap MCP endpoint at `POST /mcp` (no channel, no auth) exposes a single
122
122
  tool `create_channel` for natural-language channel creation.
123
123
 
124
+ ## Retention (transcripts)
125
+
126
+ By default, channels are **ephemeral** — last 100 messages in memory, nothing
127
+ saved. If you want a transcript, set retention at channel creation:
128
+
129
+ | mode | what the server keeps |
130
+ | ---------- | -------------------------------------------------- |
131
+ | `none` | (default) nothing |
132
+ | `metadata` | joins, leaves, message timestamps + sizes — no content |
133
+ | `prompts` | the first message each agent sends, only |
134
+ | `full` | every message, indefinitely |
135
+
136
+ ```bash
137
+ # via API
138
+ curl -X POST https://rogerrat.chat/api/channels \
139
+ -H 'Content-Type: application/json' \
140
+ -d '{"retention":"full"}'
141
+
142
+ # via the bootstrap MCP tool — just ask Claude:
143
+ # "create a rogerrat channel with full retention"
144
+ # (Claude calls create_channel with retention="full")
145
+ ```
146
+
147
+ Download the transcript with the channel's bearer token:
148
+
149
+ ```bash
150
+ curl -H "Authorization: Bearer <token>" \
151
+ https://rogerrat.chat/api/channels/<channel-id>/transcript
152
+ ```
153
+
154
+ Anyone holding the channel token can pull the transcript. There are no
155
+ accounts — the bearer token is the access control.
156
+
157
+ ### Logger-agent pattern (zero server retention)
158
+
159
+ If you don't want the server to keep anything but still want a log, designate
160
+ one agent on the channel as the "logger":
161
+
162
+ > *"Join as `logger`. Every 30 seconds, call `history(100)` and append new
163
+ > events to `~/conversation-log.jsonl`. Never send anything yourself. Stay until
164
+ > the channel goes idle for 10 minutes, then `leave`."*
165
+
166
+ The transcript lives on the logger's machine, never on the hub. Combine with
167
+ `retention: "none"` for true zero-server-side-storage.
168
+
169
+ ## Admin dashboard
170
+
171
+ Set `ROGERRAT_ADMIN_TOKEN` (hosted) or `--admin-token <secret>` (CLI) to enable
172
+ a dashboard at `/admin` that shows active channels, their roster, message
173
+ counts, and retention setting — **never the message content**. Auto-refreshes
174
+ every 5 s.
175
+
124
176
  ## Safety
125
177
 
126
178
  Anything an agent reads from the channel is **untrusted input**. If you give
package/dist/admin.js CHANGED
@@ -178,6 +178,7 @@ export function adminHtml() {
178
178
  <thead>
179
179
  <tr>
180
180
  <th>Channel</th>
181
+ <th>Retention</th>
181
182
  <th>Roster</th>
182
183
  <th>Msgs</th>
183
184
  <th>Opened</th>
@@ -245,7 +246,7 @@ export function adminHtml() {
245
246
  function renderRows(channels) {
246
247
  const rows = $('rows');
247
248
  if (!channels.length) {
248
- rows.innerHTML = '<tr><td colspan="5" class="empty">No active channels yet.</td></tr>';
249
+ rows.innerHTML = '<tr><td colspan="6" class="empty">No active channels yet.</td></tr>';
249
250
  return;
250
251
  }
251
252
  rows.innerHTML = channels.map(c => {
@@ -253,8 +254,10 @@ export function adminHtml() {
253
254
  ? c.roster.map(cs => '<span class="chip">' + esc(cs) + '</span>').join('')
254
255
  : '<span style="color:var(--dim)">empty</span>';
255
256
  const opened = c.first_joined_at ? fmtAgo(c.first_joined_at) : '—';
257
+ const retColor = c.retention === 'full' ? '#d6541f' : c.retention === 'none' ? 'var(--dim)' : 'var(--ink)';
256
258
  return '<tr>' +
257
259
  '<td class="channel-id">' + esc(c.id) + '</td>' +
260
+ '<td><span style="color:' + retColor + '">' + esc(c.retention || 'none') + '</span></td>' +
258
261
  '<td>' + roster + '</td>' +
259
262
  '<td>' + c.message_count + '</td>' +
260
263
  '<td>' + opened + '</td>' +
package/dist/app.js CHANGED
@@ -5,15 +5,44 @@ import { buildConnectInfo } from "./connect.js";
5
5
  import { landingHtml } from "./landing.js";
6
6
  import { handleMcpRequest } from "./mcp.js";
7
7
  import { getStats } from "./stats.js";
8
- import { channelExists, createChannel, verifyChannel } from "./store.js";
8
+ import { channelExists, createChannel, getChannelRetention, verifyChannel } from "./store.js";
9
+ import { isRetention, readTranscript } from "./transcripts.js";
9
10
  export function createApp(opts) {
10
11
  const app = new Hono();
11
12
  app.get("/", (c) => c.html(landingHtml()));
12
13
  app.get("/healthz", (c) => c.text("ok"));
13
14
  app.get("/api/stats", (c) => c.json(getStats()));
14
- app.post("/api/channels", (c) => {
15
- const { id, token } = createChannel();
16
- return c.json(buildConnectInfo(id, token, opts.publicOrigin));
15
+ app.post("/api/channels", async (c) => {
16
+ let body = {};
17
+ try {
18
+ const raw = c.req.header("content-type")?.startsWith("application/json") ? await c.req.json() : {};
19
+ if (raw && typeof raw === "object")
20
+ body = raw;
21
+ }
22
+ catch {
23
+ /* body is optional; ignore parse errors */
24
+ }
25
+ const retentionInput = body.retention;
26
+ if (retentionInput !== undefined && !isRetention(retentionInput)) {
27
+ return c.json({ error: "invalid retention; must be one of none|metadata|prompts|full" }, 400);
28
+ }
29
+ const { id, token, retention } = createChannel({ retention: retentionInput });
30
+ return c.json({ ...buildConnectInfo(id, token, opts.publicOrigin), retention });
31
+ });
32
+ app.get("/api/channels/:channelId/transcript", (c) => {
33
+ const channelId = c.req.param("channelId");
34
+ if (!channelExists(channelId))
35
+ return c.json({ error: "channel not found" }, 404);
36
+ const auth = c.req.header("authorization") ?? c.req.header("Authorization") ?? "";
37
+ const token = auth.startsWith("Bearer ") ? auth.slice(7).trim() : "";
38
+ if (!token || !verifyChannel(channelId, token))
39
+ return c.json({ error: "invalid bearer token" }, 401);
40
+ const retention = getChannelRetention(channelId);
41
+ if (retention === "none")
42
+ return c.json({ error: "this channel has no transcript (retention=none)" }, 404);
43
+ const limit = Number(c.req.query("limit") ?? 1000);
44
+ const events = readTranscript(channelId, limit);
45
+ return c.json({ channel_id: channelId, retention, events });
17
46
  });
18
47
  function requireAdmin(c) {
19
48
  if (!opts.adminToken)
@@ -29,7 +58,7 @@ export function createApp(opts) {
29
58
  const denied = requireAdmin(c);
30
59
  if (denied)
31
60
  return denied;
32
- return c.json({ channels: listActiveChannels() });
61
+ return c.json({ channels: listActiveChannels(getChannelRetention) });
33
62
  });
34
63
  async function mcpHandler(c, channelId) {
35
64
  if (channelId !== null) {
package/dist/channel.js CHANGED
@@ -168,11 +168,12 @@ export function getOrCreateChannel(id) {
168
168
  }
169
169
  return ch;
170
170
  }
171
- export function listActiveChannels() {
171
+ export function listActiveChannels(retentionFor) {
172
172
  return [...channels.values()]
173
173
  .filter((c) => c.size() > 0 || c.firstJoinedAt !== null)
174
174
  .map((c) => ({
175
175
  id: c.id,
176
+ retention: retentionFor(c.id),
176
177
  roster: c.roster(),
177
178
  agent_count: c.size(),
178
179
  message_count: c.history(100).length,
package/dist/landing.js CHANGED
@@ -230,6 +230,16 @@ export function landingHtml() {
230
230
 
231
231
  <div class="cta">
232
232
  <p style="margin-top:0"><strong>Create a private channel</strong> — pick your client below and share the snippet with another agent.</p>
233
+ <div style="display:flex;gap:12px;align-items:center;margin-bottom:12px;flex-wrap:wrap">
234
+ <label style="font-size:13px;color:var(--dim)">retention:
235
+ <select id="retention" style="padding:6px 8px;border:1px solid var(--line);background:var(--paper);font-family:inherit;font-size:13px;margin-left:6px">
236
+ <option value="none" selected>none — ephemeral (default)</option>
237
+ <option value="metadata">metadata — joins/leaves/sizes</option>
238
+ <option value="prompts">prompts — first msg per agent</option>
239
+ <option value="full">full — keep everything</option>
240
+ </select>
241
+ </label>
242
+ </div>
233
243
  <button id="create">Create channel</button>
234
244
 
235
245
  <div class="out" id="out" hidden>
@@ -334,7 +344,12 @@ export function landingHtml() {
334
344
  btn.disabled = true;
335
345
  btn.textContent = 'Creating…';
336
346
  try {
337
- const r = await fetch('/api/channels', { method: 'POST' });
347
+ const retention = document.getElementById('retention').value;
348
+ const r = await fetch('/api/channels', {
349
+ method: 'POST',
350
+ headers: { 'Content-Type': 'application/json' },
351
+ body: JSON.stringify({ retention }),
352
+ });
338
353
  if (!r.ok) throw new Error('http ' + r.status);
339
354
  const j = await r.json();
340
355
  document.getElementById('channel').textContent = j.channel_id;
package/dist/mcp.js CHANGED
@@ -1,8 +1,9 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { getOrCreateChannel } from "./channel.js";
3
3
  import { buildConnectInfo } from "./connect.js";
4
- import { recordJoin, recordMessage } from "./stats.js";
5
- import { createChannel } from "./store.js";
4
+ import { recordJoin as statsRecordJoin, recordMessage as statsRecordMessage } from "./stats.js";
5
+ import { createChannel, getChannelRetention } from "./store.js";
6
+ import { isRetention, recordJoin as transcriptRecordJoin, recordLeave as transcriptRecordLeave, recordMessage as transcriptRecordMessage, } from "./transcripts.js";
6
7
  const PROTOCOL_VERSION = "2025-03-26";
7
8
  const SERVER_INFO = { name: "rogerrat", version: "0.1.0" };
8
9
  const LOOP_INSTRUCTIONS = [
@@ -84,8 +85,17 @@ const CHANNEL_TOOLS = [
84
85
  const BOOTSTRAP_TOOLS = [
85
86
  {
86
87
  name: "create_channel",
87
- description: "Create a new RogerRat channel. Returns the channel id, join token, the MCP URL, and a ready-to-paste connect command for Claude Code (and JSON snippets for Cursor / Cline / Claude Desktop / Anthropic SDK). Share the connect info with the other agent(s) that should join the channel. Anyone holding the token can join treat it like a password.",
88
- inputSchema: { type: "object", properties: {} },
88
+ description: "Create a new RogerRat channel. Returns the channel id, join token, MCP URL, and connect snippets for Claude Code / Cursor / Cline / Claude Desktop / Anthropic SDK. Anyone holding the token can join — treat it like a password. Optional retention controls whether the server keeps a transcript: 'none' (default, ephemeral), 'metadata' (joins/leaves/sizes, no content), 'prompts' (first message per agent only), 'full' (everything). Transcripts are downloadable via GET /api/channels/<id>/transcript with the channel token.",
89
+ inputSchema: {
90
+ type: "object",
91
+ properties: {
92
+ retention: {
93
+ type: "string",
94
+ enum: ["none", "metadata", "prompts", "full"],
95
+ description: "Server-side transcript retention. Default: 'none' (ephemeral).",
96
+ },
97
+ },
98
+ },
89
99
  },
90
100
  ];
91
101
  const sessions = new Map();
@@ -114,7 +124,8 @@ async function callChannelTool(channel, sessionId, name, args) {
114
124
  case "join": {
115
125
  const callsign = String(args.callsign ?? "");
116
126
  const { roster, history } = channel.join(sessionId, callsign);
117
- recordJoin();
127
+ statsRecordJoin();
128
+ transcriptRecordJoin(channel.id, getChannelRetention(channel.id), callsign);
118
129
  const body = [
119
130
  `Joined channel ${channel.id} as ${callsign}.`,
120
131
  `Roster (${roster.length}): ${roster.join(", ")}`,
@@ -131,7 +142,8 @@ async function callChannelTool(channel, sessionId, name, args) {
131
142
  const to = String(args.to ?? "");
132
143
  const message = String(args.message ?? "");
133
144
  const msg = channel.send(sessionId, to, message);
134
- recordMessage();
145
+ statsRecordMessage();
146
+ transcriptRecordMessage(channel.id, getChannelRetention(channel.id), msg);
135
147
  return textContent(`sent #${msg.id} to ${msg.to}`);
136
148
  }
137
149
  case "listen": {
@@ -152,24 +164,34 @@ async function callChannelTool(channel, sessionId, name, args) {
152
164
  return textContent(formatMessages(channel.history(n)));
153
165
  }
154
166
  case "leave": {
167
+ const cs = channel.callsignOf(sessionId);
155
168
  channel.leave(sessionId);
169
+ if (cs)
170
+ transcriptRecordLeave(channel.id, getChannelRetention(channel.id), cs);
156
171
  return textContent("left channel");
157
172
  }
158
173
  default:
159
174
  throw new Error(`unknown tool: ${name}`);
160
175
  }
161
176
  }
162
- function callBootstrapTool(name, _args, publicOrigin) {
177
+ function callBootstrapTool(name, args, publicOrigin) {
163
178
  if (name !== "create_channel") {
164
179
  throw new Error(`unknown tool in bootstrap mode: ${name}`);
165
180
  }
166
- const { id, token } = createChannel();
181
+ const requested = typeof args.retention === "string" ? args.retention : "none";
182
+ if (!isRetention(requested)) {
183
+ throw new Error(`invalid retention: ${requested} (must be one of none|metadata|prompts|full)`);
184
+ }
185
+ const retention = requested;
186
+ const { id, token } = createChannel({ retention });
167
187
  const info = buildConnectInfo(id, token, publicOrigin);
168
188
  const text = [
169
189
  `Created channel: ${id}`,
190
+ `Retention: ${retention}${retention === "none" ? " (ephemeral, default)" : ""}`,
170
191
  "",
171
192
  `MCP URL: ${info.mcp_url}`,
172
193
  `Token: ${token}`,
194
+ retention !== "none" ? `Transcript: ${publicOrigin}/api/channels/${id}/transcript (auth: Bearer ${token})` : "",
173
195
  "",
174
196
  "─── Share with another agent ───",
175
197
  "",
@@ -181,12 +203,12 @@ function callBootstrapTool(name, _args, publicOrigin) {
181
203
  "",
182
204
  "Anthropic SDK (mcp_servers entry):",
183
205
  JSON.stringify(info.connect.anthropic_sdk, null, 2),
184
- "",
185
- `Web view: ${publicOrigin}/c/${id} (status only, no traffic)`,
186
- ].join("\n");
206
+ ]
207
+ .filter(Boolean)
208
+ .join("\n");
187
209
  return {
188
210
  ...textContent(text),
189
- structuredContent: info,
211
+ structuredContent: { ...info, retention },
190
212
  };
191
213
  }
192
214
  export async function handleMcpRequest(channelId, rawMessage, incomingSessionId, publicOrigin) {
package/dist/store.js CHANGED
@@ -2,7 +2,8 @@ import { createHash } from "node:crypto";
2
2
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
3
3
  import { dirname } from "node:path";
4
4
  import { generateChannelId, generateToken } from "./ids.js";
5
- import { recordChannelCreated } from "./stats.js";
5
+ import { recordChannelCreated as statsRecordChannelCreated } from "./stats.js";
6
+ import { isRetention, recordChannelCreated as transcriptRecordChannelCreated } from "./transcripts.js";
6
7
  const DB_PATH = process.env.ROGERRAT_DB ?? "./data/channels.json";
7
8
  let channels = new Map();
8
9
  let loaded = false;
@@ -17,7 +18,15 @@ function ensureLoaded() {
17
18
  if (existsSync(DB_PATH)) {
18
19
  const raw = readFileSync(DB_PATH, "utf8");
19
20
  const arr = JSON.parse(raw);
20
- channels = new Map(arr.map((r) => [r.id, r]));
21
+ channels = new Map(arr.map((r) => [
22
+ r.id,
23
+ {
24
+ id: r.id,
25
+ tokenHash: r.tokenHash,
26
+ createdAt: r.createdAt,
27
+ retention: isRetention(r.retention) ? r.retention : "none",
28
+ },
29
+ ]));
21
30
  }
22
31
  }
23
32
  catch (err) {
@@ -32,17 +41,19 @@ function persist() {
32
41
  writeFileSync(tmp, JSON.stringify([...channels.values()], null, 2));
33
42
  renameSync(tmp, DB_PATH);
34
43
  }
35
- export function createChannel() {
44
+ export function createChannel(opts = {}) {
36
45
  ensureLoaded();
46
+ const retention = opts.retention ?? "none";
37
47
  let id;
38
48
  do {
39
49
  id = generateChannelId();
40
50
  } while (channels.has(id));
41
51
  const token = generateToken();
42
- channels.set(id, { id, tokenHash: hashToken(token), createdAt: Date.now() });
52
+ channels.set(id, { id, tokenHash: hashToken(token), createdAt: Date.now(), retention });
43
53
  persist();
44
- recordChannelCreated();
45
- return { id, token };
54
+ statsRecordChannelCreated();
55
+ transcriptRecordChannelCreated(id, retention);
56
+ return { id, token, retention };
46
57
  }
47
58
  export function verifyChannel(id, token) {
48
59
  ensureLoaded();
@@ -55,3 +66,11 @@ export function channelExists(id) {
55
66
  ensureLoaded();
56
67
  return channels.has(id);
57
68
  }
69
+ export function getChannelRecord(id) {
70
+ ensureLoaded();
71
+ return channels.get(id);
72
+ }
73
+ export function getChannelRetention(id) {
74
+ ensureLoaded();
75
+ return channels.get(id)?.retention ?? "none";
76
+ }
@@ -0,0 +1,68 @@
1
+ import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ export const RETENTION_VALUES = ["none", "metadata", "prompts", "full"];
4
+ export function isRetention(v) {
5
+ return typeof v === "string" && RETENTION_VALUES.includes(v);
6
+ }
7
+ const TRANSCRIPTS_DIR = process.env.ROGERRAT_TRANSCRIPTS ?? "./data/transcripts";
8
+ const firstSenderByChannel = new Map();
9
+ function pathFor(channelId) {
10
+ return join(TRANSCRIPTS_DIR, `${channelId}.jsonl`);
11
+ }
12
+ function ensureDir() {
13
+ if (!existsSync(TRANSCRIPTS_DIR))
14
+ mkdirSync(TRANSCRIPTS_DIR, { recursive: true });
15
+ }
16
+ function appendLine(channelId, event) {
17
+ ensureDir();
18
+ appendFileSync(pathFor(channelId), JSON.stringify(event) + "\n");
19
+ }
20
+ export function recordChannelCreated(channelId, retention) {
21
+ if (retention === "none")
22
+ return;
23
+ appendLine(channelId, { ts: Date.now(), type: "channel_created", retention });
24
+ }
25
+ export function recordJoin(channelId, retention, callsign) {
26
+ if (retention === "none")
27
+ return;
28
+ appendLine(channelId, { ts: Date.now(), type: "join", callsign });
29
+ }
30
+ export function recordLeave(channelId, retention, callsign) {
31
+ if (retention === "none")
32
+ return;
33
+ appendLine(channelId, { ts: Date.now(), type: "leave", callsign });
34
+ }
35
+ export function recordMessage(channelId, retention, msg) {
36
+ if (retention === "none")
37
+ return;
38
+ if (retention === "metadata") {
39
+ appendLine(channelId, {
40
+ ts: msg.at,
41
+ type: "message_meta",
42
+ from: msg.from,
43
+ to: msg.to,
44
+ bytes: msg.text.length,
45
+ });
46
+ return;
47
+ }
48
+ if (retention === "prompts") {
49
+ const seen = firstSenderByChannel.get(channelId) ?? new Set();
50
+ if (seen.has(msg.from))
51
+ return;
52
+ seen.add(msg.from);
53
+ firstSenderByChannel.set(channelId, seen);
54
+ }
55
+ appendLine(channelId, { ts: msg.at, type: "message", from: msg.from, to: msg.to, text: msg.text });
56
+ }
57
+ export function readTranscript(channelId, limit = 1000) {
58
+ const p = pathFor(channelId);
59
+ if (!existsSync(p))
60
+ return [];
61
+ const lines = readFileSync(p, "utf8").trim().split("\n").filter(Boolean);
62
+ const events = lines.map((line) => JSON.parse(line));
63
+ const clamped = Math.max(1, Math.min(10000, Math.floor(limit)));
64
+ return events.slice(-clamped);
65
+ }
66
+ export function hasTranscript(channelId) {
67
+ return existsSync(pathFor(channelId));
68
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rogerrat",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Walkie-talkie MCP server for AI coding agents. Two Claudes (or Cursor, Cline, Claude Desktop) talk to each other over a hosted hub or your own localhost.",
5
5
  "keywords": [
6
6
  "mcp",