rogerrat 1.19.0 → 1.20.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 +10 -2
- package/dist/channel.js +35 -1
- package/dist/listen-here.js +16 -6
- package/dist/mcp.js +16 -6
- package/dist/remote-ui.js +36 -0
- package/package.json +1 -1
package/dist/app.js
CHANGED
|
@@ -4,7 +4,7 @@ import { dirname, join as joinPath } from "node:path";
|
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { Hono } from "hono";
|
|
6
6
|
import { streamSSE } from "hono/streaming";
|
|
7
|
-
import { ChannelError, isPriority, setSessionTtlLookup, startPeriodicGc, } from "./channel.js";
|
|
7
|
+
import { ChannelError, isPriority, setSessionTtlLookup, startPeriodicGc, validateSuggestedReplies, } from "./channel.js";
|
|
8
8
|
import { attachEmail, confirmEmailRecovery, createAccount, createIdentity, deleteIdentity, getAccount, getAccountIdsByIdentityCallsign, listIdentities, recoverAccount, removeEmail, requestEmailRecovery, verifyEmailCode, verifyIdentity, verifySession, } from "./accounts.js";
|
|
9
9
|
import { createChannelWebhook, createWebhook, deleteChannelWebhook, deleteWebhook, deliver, getActiveWebhooksForAccount, getActiveWebhooksForChannel, listChannelWebhooks, listWebhooks, } from "./webhooks.js";
|
|
10
10
|
import { buildRecoveryEmail, buildVerifyEmail, emailEnabled, sendEmail } from "./email.js";
|
|
@@ -748,6 +748,13 @@ export function createApp(opts) {
|
|
|
748
748
|
if (priorityInput !== undefined && !isPriority(priorityInput)) {
|
|
749
749
|
return c.json({ error: "invalid priority; must be one of min|low|default|high|urgent", code: "invalid" }, 400);
|
|
750
750
|
}
|
|
751
|
+
let suggestedReplies;
|
|
752
|
+
try {
|
|
753
|
+
suggestedReplies = validateSuggestedReplies(body.suggested_replies);
|
|
754
|
+
}
|
|
755
|
+
catch (e) {
|
|
756
|
+
return handleChannelError(c, e);
|
|
757
|
+
}
|
|
751
758
|
const channel = getOrCreateChannel(channelId);
|
|
752
759
|
try {
|
|
753
760
|
const isBand = getChannelIsBand(channelId);
|
|
@@ -760,7 +767,7 @@ export function createApp(opts) {
|
|
|
760
767
|
retry_after_seconds: rate.retryAfter,
|
|
761
768
|
}, 429);
|
|
762
769
|
}
|
|
763
|
-
const msg = channel.send(sessionId, to, message, priorityInput);
|
|
770
|
+
const msg = channel.send(sessionId, to, message, priorityInput, suggestedReplies);
|
|
764
771
|
statsRecordMessage();
|
|
765
772
|
transcriptRecordMessage(channelId, getChannelRetention(channelId), msg);
|
|
766
773
|
fanoutWebhooks(channelId, msg);
|
|
@@ -772,6 +779,7 @@ export function createApp(opts) {
|
|
|
772
779
|
queued,
|
|
773
780
|
to: msg.to,
|
|
774
781
|
...(msg.priority ? { priority: msg.priority } : {}),
|
|
782
|
+
...(msg.suggested_replies ? { suggested_replies: msg.suggested_replies } : {}),
|
|
775
783
|
});
|
|
776
784
|
}
|
|
777
785
|
catch (e) {
|
package/dist/channel.js
CHANGED
|
@@ -8,6 +8,37 @@ export const PRIORITY_RANK = {
|
|
|
8
8
|
export function isPriority(v) {
|
|
9
9
|
return v === "min" || v === "low" || v === "default" || v === "high" || v === "urgent";
|
|
10
10
|
}
|
|
11
|
+
export const MAX_SUGGESTED_REPLIES = 4;
|
|
12
|
+
export const MAX_SUGGESTED_REPLY_LENGTH = 64;
|
|
13
|
+
/** Validate + normalize a suggested_replies array. Returns the cleaned array
|
|
14
|
+
* or throws a ChannelError describing what's wrong. Returns undefined if
|
|
15
|
+
* the input is undefined (the caller meant "no suggestions"). */
|
|
16
|
+
export function validateSuggestedReplies(v) {
|
|
17
|
+
if (v === undefined || v === null)
|
|
18
|
+
return undefined;
|
|
19
|
+
if (!Array.isArray(v)) {
|
|
20
|
+
throw new ChannelError("suggested_replies must be an array of strings", "invalid", 400);
|
|
21
|
+
}
|
|
22
|
+
if (v.length === 0)
|
|
23
|
+
return undefined; // empty array = same as omitted
|
|
24
|
+
if (v.length > MAX_SUGGESTED_REPLIES) {
|
|
25
|
+
throw new ChannelError(`suggested_replies: max ${MAX_SUGGESTED_REPLIES} entries (got ${v.length})`, "invalid", 400);
|
|
26
|
+
}
|
|
27
|
+
const out = [];
|
|
28
|
+
for (const item of v) {
|
|
29
|
+
if (typeof item !== "string") {
|
|
30
|
+
throw new ChannelError("suggested_replies entries must all be strings", "invalid", 400);
|
|
31
|
+
}
|
|
32
|
+
const trimmed = item.trim();
|
|
33
|
+
if (!trimmed)
|
|
34
|
+
continue; // skip empty strings silently
|
|
35
|
+
if (trimmed.length > MAX_SUGGESTED_REPLY_LENGTH) {
|
|
36
|
+
throw new ChannelError(`suggested_replies entry too long (max ${MAX_SUGGESTED_REPLY_LENGTH} chars)`, "invalid", 400);
|
|
37
|
+
}
|
|
38
|
+
out.push(trimmed);
|
|
39
|
+
}
|
|
40
|
+
return out.length > 0 ? out : undefined;
|
|
41
|
+
}
|
|
11
42
|
const HISTORY_CAP = 100;
|
|
12
43
|
// Default idle TTL; channels can override via session_ttl_seconds at creation (max 24h).
|
|
13
44
|
const DEFAULT_ROSTER_IDLE_MS = 30 * 60 * 1000;
|
|
@@ -203,7 +234,7 @@ export class Channel {
|
|
|
203
234
|
sessionExists(sessionId) {
|
|
204
235
|
return this.callsignBySession.has(sessionId);
|
|
205
236
|
}
|
|
206
|
-
send(sessionId, to, text, priority) {
|
|
237
|
+
send(sessionId, to, text, priority, suggestedReplies) {
|
|
207
238
|
this.ensureJoined(sessionId);
|
|
208
239
|
const from = this.callsignBySession.get(sessionId);
|
|
209
240
|
const dest = this.resolveAddress(to);
|
|
@@ -228,6 +259,9 @@ export class Channel {
|
|
|
228
259
|
// backward-compatible for consumers that don't know about priorities.
|
|
229
260
|
if (priority && priority !== "default")
|
|
230
261
|
msg.priority = priority;
|
|
262
|
+
if (suggestedReplies && suggestedReplies.length > 0) {
|
|
263
|
+
msg.suggested_replies = suggestedReplies;
|
|
264
|
+
}
|
|
231
265
|
this.messages.push(msg);
|
|
232
266
|
if (this.messages.length > HISTORY_CAP)
|
|
233
267
|
this.messages.shift();
|
package/dist/listen-here.js
CHANGED
|
@@ -36,15 +36,17 @@ options:
|
|
|
36
36
|
--since <msg_id> resume from a known message id (skips per-session cursor)
|
|
37
37
|
--on-message <cmd> shell command to run for each delivered message; env vars
|
|
38
38
|
RR_MESSAGE, RR_FROM, RR_TO, RR_MSG_ID, RR_CHANNEL,
|
|
39
|
-
RR_PRIORITY are set
|
|
39
|
+
RR_PRIORITY, RR_REPLIES are set
|
|
40
|
+
(RR_PRIORITY = "default" when omitted;
|
|
41
|
+
RR_REPLIES = tab-separated suggested replies, empty if none)
|
|
40
42
|
--inbox <file> append each message to this file (parent dir created if
|
|
41
43
|
missing). Format controlled by --format.
|
|
42
44
|
--format <fmt> output format for stdout and --inbox lines:
|
|
43
|
-
jsonl (default) — one JSON object per line: {id,from,to,text,at,priority?}
|
|
45
|
+
jsonl (default) — one JSON object per line: {id,from,to,text,at,priority?,suggested_replies?}
|
|
44
46
|
text — one line per message:
|
|
45
|
-
"[<priority>] [<from>] <text>
|
|
46
|
-
|
|
47
|
-
|
|
47
|
+
"[<priority>] [<from>] <text> → [r1] [r2]"
|
|
48
|
+
where [<priority>] and → [r1] [r2] only appear
|
|
49
|
+
when set. Newlines in text collapsed to spaces.
|
|
48
50
|
Use this when feeding a Monitor/tail consumer
|
|
49
51
|
so each line is a human-readable notification
|
|
50
52
|
with no parser needed in the watcher.
|
|
@@ -196,7 +198,12 @@ function formatLine(args, msg) {
|
|
|
196
198
|
// Surface non-default priority as a leading tag so a Monitor tail of the
|
|
197
199
|
// inbox file can grep for urgency without parsing JSON. e.g. `[urgent] `
|
|
198
200
|
const prio = msg.priority && msg.priority !== "default" ? `[${msg.priority}] ` : "";
|
|
199
|
-
|
|
201
|
+
// Surface suggested_replies as a trailing → [yes] [no] hint so the
|
|
202
|
+
// agent sees the canned options without parsing JSON.
|
|
203
|
+
const replies = msg.suggested_replies && msg.suggested_replies.length > 0
|
|
204
|
+
? " → " + msg.suggested_replies.map((r) => `[${r}]`).join(" ")
|
|
205
|
+
: "";
|
|
206
|
+
return `${prio}[${msg.from}] ${flat}${replies}`;
|
|
200
207
|
}
|
|
201
208
|
return JSON.stringify(msg);
|
|
202
209
|
}
|
|
@@ -235,6 +242,9 @@ async function dispatch(args, msg) {
|
|
|
235
242
|
RR_MSG_ID: String(msg.id),
|
|
236
243
|
RR_CHANNEL: args.channel,
|
|
237
244
|
RR_PRIORITY: msg.priority ?? "default",
|
|
245
|
+
// Tab-separated so the hook can split on $'\t' cleanly even if a reply
|
|
246
|
+
// contains spaces or punctuation. Empty string when no suggestions.
|
|
247
|
+
RR_REPLIES: (msg.suggested_replies ?? []).join("\t"),
|
|
238
248
|
},
|
|
239
249
|
});
|
|
240
250
|
child.on("error", (err) => {
|
package/dist/mcp.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { createAccount, createIdentity as accountCreateIdentity, verifyIdentity, verifySession } from "./accounts.js";
|
|
3
|
-
import { getOrCreateChannel, isPriority } from "./channel.js";
|
|
3
|
+
import { getOrCreateChannel, isPriority, validateSuggestedReplies, } from "./channel.js";
|
|
4
4
|
import { buildConnectInfo } from "./connect.js";
|
|
5
5
|
import { createRemoteControl } from "./remote-control.js";
|
|
6
6
|
import { getPreset } from "./presets.js";
|
|
@@ -169,7 +169,7 @@ const UNIFIED_TOOLS = [
|
|
|
169
169
|
},
|
|
170
170
|
{
|
|
171
171
|
name: "send",
|
|
172
|
-
description: "Send a message to another agent on the channel you joined, or to 'all' to broadcast. Requires a prior join() in this session. The 'to' field accepts: a callsign ('front'), an index ('#1' or '1') from roster(), or 'all'. Optional `priority` tags urgency (min|low|default|high|urgent) — receivers
|
|
172
|
+
description: "Send a message to another agent on the channel you joined, or to 'all' to broadcast. Requires a prior join() in this session. The 'to' field accepts: a callsign ('front'), an index ('#1' or '1') from roster(), or 'all'. Optional `priority` tags urgency (min|low|default|high|urgent). Optional `suggested_replies` hints up to 4 canned replies that human-in-the-loop UIs (like the /remote phone view) render as tappable chips — agent receivers can read them too and pick one.",
|
|
173
173
|
inputSchema: {
|
|
174
174
|
type: "object",
|
|
175
175
|
properties: {
|
|
@@ -180,6 +180,12 @@ const UNIFIED_TOOLS = [
|
|
|
180
180
|
enum: ["min", "low", "default", "high", "urgent"],
|
|
181
181
|
description: "Optional urgency tag. Default = 'default'. The server doesn't enforce semantics — receivers (listen-here, agents, webhooks) interpret. Use 'urgent' when the peer should wake right now; 'low' or 'min' for background updates the peer can batch.",
|
|
182
182
|
},
|
|
183
|
+
suggested_replies: {
|
|
184
|
+
type: "array",
|
|
185
|
+
items: { type: "string", maxLength: 64 },
|
|
186
|
+
maxItems: 4,
|
|
187
|
+
description: "Optional array of up to 4 short canned replies (max 64 chars each). Useful when asking the peer a multiple-choice-ish question, especially in human-in-the-loop channels. E.g. send 'shall I deploy?' with suggested_replies=['yes', 'no', 'show me the diff']. The 'click' from a receiver is just a normal /send with that text.",
|
|
188
|
+
},
|
|
183
189
|
},
|
|
184
190
|
required: ["to", "message"],
|
|
185
191
|
},
|
|
@@ -327,12 +333,14 @@ async function callChannelTool(channel, sessionId, name, args) {
|
|
|
327
333
|
const to = String(args.to ?? "");
|
|
328
334
|
const message = String(args.message ?? args.text ?? "");
|
|
329
335
|
const priority = isPriority(args.priority) ? args.priority : undefined;
|
|
330
|
-
const
|
|
336
|
+
const suggestedReplies = validateSuggestedReplies(args.suggested_replies);
|
|
337
|
+
const msg = channel.send(sessionId, to, message, priority, suggestedReplies);
|
|
331
338
|
statsRecordMessage();
|
|
332
339
|
transcriptRecordMessage(channel.id, getChannelRetention(channel.id), msg);
|
|
333
340
|
const queued = msg.to !== "all" && !channel.isCallsignOnline(msg.to);
|
|
334
341
|
const prio = msg.priority ? ` [${msg.priority}]` : "";
|
|
335
|
-
|
|
342
|
+
const replies = msg.suggested_replies ? ` (suggested: ${msg.suggested_replies.join(" | ")})` : "";
|
|
343
|
+
return textContent(`sent #${msg.id}${prio} to ${msg.to}${queued ? " (queued — recipient is offline, will be delivered when they rejoin)" : ""}${replies}`);
|
|
336
344
|
}
|
|
337
345
|
case "listen": {
|
|
338
346
|
const seconds = typeof args.timeout_seconds === "number" ? args.timeout_seconds : 30;
|
|
@@ -611,12 +619,14 @@ async function callUnifiedTool(name, args, state, sessionId, publicOrigin, mode
|
|
|
611
619
|
const to = String(args.to ?? "");
|
|
612
620
|
const message = String(args.message ?? args.text ?? "");
|
|
613
621
|
const priority = isPriority(args.priority) ? args.priority : undefined;
|
|
614
|
-
const
|
|
622
|
+
const suggestedReplies = validateSuggestedReplies(args.suggested_replies);
|
|
623
|
+
const msg = channel.send(sessionId, to, message, priority, suggestedReplies);
|
|
615
624
|
statsRecordMessage();
|
|
616
625
|
transcriptRecordMessage(channel.id, getChannelRetention(channel.id), msg);
|
|
617
626
|
const queued = msg.to !== "all" && !channel.isCallsignOnline(msg.to);
|
|
618
627
|
const prio = msg.priority ? ` [${msg.priority}]` : "";
|
|
619
|
-
|
|
628
|
+
const replies = msg.suggested_replies ? ` (suggested: ${msg.suggested_replies.join(" | ")})` : "";
|
|
629
|
+
return textContent(`sent #${msg.id}${prio} to ${msg.to}${queued ? " (queued — recipient is offline, will be delivered when they rejoin)" : ""}${replies}`);
|
|
620
630
|
}
|
|
621
631
|
case "listen": {
|
|
622
632
|
const seconds = typeof args.timeout_seconds === "number" ? args.timeout_seconds : 30;
|
package/dist/remote-ui.js
CHANGED
|
@@ -113,6 +113,19 @@ export function remoteHtml(channelId) {
|
|
|
113
113
|
}
|
|
114
114
|
.msg.sys.error .bubble { color: var(--warn); }
|
|
115
115
|
.msg .when { font-size: 10px; color: var(--dim); padding: 0 6px; }
|
|
116
|
+
.msg .chips {
|
|
117
|
+
display: flex; flex-wrap: wrap; gap: 6px;
|
|
118
|
+
margin-top: 4px; padding: 0 4px;
|
|
119
|
+
}
|
|
120
|
+
.msg .chip {
|
|
121
|
+
font-family: inherit; font-size: 13px;
|
|
122
|
+
padding: 6px 12px;
|
|
123
|
+
background: var(--paper); color: var(--ink);
|
|
124
|
+
border: 1px solid var(--warn); border-radius: 14px;
|
|
125
|
+
cursor: pointer; touch-action: manipulation;
|
|
126
|
+
transition: background 0.1s;
|
|
127
|
+
}
|
|
128
|
+
.msg .chip:active { background: var(--warn); color: white; }
|
|
116
129
|
|
|
117
130
|
footer {
|
|
118
131
|
flex-shrink: 0; background: var(--paper);
|
|
@@ -315,6 +328,29 @@ export function remoteHtml(channelId) {
|
|
|
315
328
|
var when = document.createElement('div'); when.className = 'when';
|
|
316
329
|
when.textContent = fmtTime(m.at);
|
|
317
330
|
d.appendChild(who); d.appendChild(b); d.appendChild(when);
|
|
331
|
+
// Suggested replies → render as tappable chips below the bubble (only on
|
|
332
|
+
// incoming msgs; if I sent it, no point clicking my own suggestion).
|
|
333
|
+
if (!mine && m.suggested_replies && m.suggested_replies.length){
|
|
334
|
+
var chipBar = document.createElement('div');
|
|
335
|
+
chipBar.className = 'chips';
|
|
336
|
+
for (var i = 0; i < m.suggested_replies.length; i++){
|
|
337
|
+
var chip = document.createElement('button');
|
|
338
|
+
chip.type = 'button';
|
|
339
|
+
chip.className = 'chip';
|
|
340
|
+
chip.textContent = m.suggested_replies[i];
|
|
341
|
+
// Capture in closure-friendly way for older mobile JS engines
|
|
342
|
+
chip.addEventListener('click', (function(text, fromCallsign){
|
|
343
|
+
return function(){
|
|
344
|
+
elInput.value = text;
|
|
345
|
+
elTarget.value = fromCallsign; // reply to whoever sent the suggestion
|
|
346
|
+
autosize();
|
|
347
|
+
send();
|
|
348
|
+
};
|
|
349
|
+
})(m.suggested_replies[i], m.from));
|
|
350
|
+
chipBar.appendChild(chip);
|
|
351
|
+
}
|
|
352
|
+
d.appendChild(chipBar);
|
|
353
|
+
}
|
|
318
354
|
elLog.appendChild(d);
|
|
319
355
|
scrollDown();
|
|
320
356
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rogerrat",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.20.0",
|
|
4
4
|
"mcpName": "io.github.opcastil11/rogerrat",
|
|
5
5
|
"description": "Real-time chat for AI agents. A walkie-talkie hub that lets two or more agents — Claude Code, Cursor, Cline, Claude Desktop, Codex — on different machines send messages to each other over MCP or plain REST. Hosted at rogerrat.chat or self-hosted with `npx rogerrat`.",
|
|
6
6
|
"keywords": [
|