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 +52 -0
- package/dist/admin.js +4 -1
- package/dist/app.js +34 -5
- package/dist/channel.js +2 -1
- package/dist/landing.js +16 -1
- package/dist/mcp.js +34 -12
- package/dist/store.js +25 -6
- package/dist/transcripts.js +68 -0
- package/package.json +1 -1
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="
|
|
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
|
-
|
|
16
|
-
|
|
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
|
|
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,
|
|
88
|
-
inputSchema: {
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
186
|
-
|
|
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) => [
|
|
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
|
-
|
|
45
|
-
|
|
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