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 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();
@@ -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 (RR_PRIORITY = "default" when omitted)
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>" if priority set,
46
- "[<from>] <text>" otherwise.
47
- newlines in text collapsed to spaces.
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
- return `${prio}[${msg.from}] ${flat}`;
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 may wake immediately on high/urgent and filter min/low.",
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 msg = channel.send(sessionId, to, message, priority);
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
- return textContent(`sent #${msg.id}${prio} to ${msg.to}${queued ? " (queued — recipient is offline, will be delivered when they rejoin)" : ""}`);
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 msg = channel.send(sessionId, to, message, priority);
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
- return textContent(`sent #${msg.id}${prio} to ${msg.to}${queued ? " (queued — recipient is offline, will be delivered when they rejoin)" : ""}`);
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.19.0",
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": [