rogerrat 1.18.1 → 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, 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";
@@ -743,6 +743,18 @@ export function createApp(opts) {
743
743
  const to = String(body.to ?? "");
744
744
  // Accept either `message` or `text` (transcripts return `text`, so clients reasonably try both).
745
745
  const message = String(body.message ?? body.text ?? "");
746
+ // Optional ntfy-style priority. Server stores it; receivers decide what to do.
747
+ const priorityInput = body.priority;
748
+ if (priorityInput !== undefined && !isPriority(priorityInput)) {
749
+ return c.json({ error: "invalid priority; must be one of min|low|default|high|urgent", code: "invalid" }, 400);
750
+ }
751
+ let suggestedReplies;
752
+ try {
753
+ suggestedReplies = validateSuggestedReplies(body.suggested_replies);
754
+ }
755
+ catch (e) {
756
+ return handleChannelError(c, e);
757
+ }
746
758
  const channel = getOrCreateChannel(channelId);
747
759
  try {
748
760
  const isBand = getChannelIsBand(channelId);
@@ -755,12 +767,20 @@ export function createApp(opts) {
755
767
  retry_after_seconds: rate.retryAfter,
756
768
  }, 429);
757
769
  }
758
- const msg = channel.send(sessionId, to, message);
770
+ const msg = channel.send(sessionId, to, message, priorityInput, suggestedReplies);
759
771
  statsRecordMessage();
760
772
  transcriptRecordMessage(channelId, getChannelRetention(channelId), msg);
761
773
  fanoutWebhooks(channelId, msg);
762
774
  const queued = msg.to !== "all" && !channel.isCallsignOnline(msg.to);
763
- return c.json({ ok: true, id: msg.id, at: msg.at, queued, to: msg.to });
775
+ return c.json({
776
+ ok: true,
777
+ id: msg.id,
778
+ at: msg.at,
779
+ queued,
780
+ to: msg.to,
781
+ ...(msg.priority ? { priority: msg.priority } : {}),
782
+ ...(msg.suggested_replies ? { suggested_replies: msg.suggested_replies } : {}),
783
+ });
764
784
  }
765
785
  catch (e) {
766
786
  return handleChannelError(c, e);
package/dist/channel.js CHANGED
@@ -1,3 +1,44 @@
1
+ export const PRIORITY_RANK = {
2
+ min: 0,
3
+ low: 1,
4
+ default: 2,
5
+ high: 3,
6
+ urgent: 4,
7
+ };
8
+ export function isPriority(v) {
9
+ return v === "min" || v === "low" || v === "default" || v === "high" || v === "urgent";
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
+ }
1
42
  const HISTORY_CAP = 100;
2
43
  // Default idle TTL; channels can override via session_ttl_seconds at creation (max 24h).
3
44
  const DEFAULT_ROSTER_IDLE_MS = 30 * 60 * 1000;
@@ -193,7 +234,7 @@ export class Channel {
193
234
  sessionExists(sessionId) {
194
235
  return this.callsignBySession.has(sessionId);
195
236
  }
196
- send(sessionId, to, text) {
237
+ send(sessionId, to, text, priority, suggestedReplies) {
197
238
  this.ensureJoined(sessionId);
198
239
  const from = this.callsignBySession.get(sessionId);
199
240
  const dest = this.resolveAddress(to);
@@ -214,6 +255,13 @@ export class Channel {
214
255
  const now = Date.now();
215
256
  this.nextMsgId = Math.max(now, this.nextMsgId + 1);
216
257
  const msg = { id: this.nextMsgId, from, to: dest, text, at: now };
258
+ // Only attach `priority` when explicitly non-default — keeps the wire format
259
+ // backward-compatible for consumers that don't know about priorities.
260
+ if (priority && priority !== "default")
261
+ msg.priority = priority;
262
+ if (suggestedReplies && suggestedReplies.length > 0) {
263
+ msg.suggested_replies = suggestedReplies;
264
+ }
217
265
  this.messages.push(msg);
218
266
  if (this.messages.length > HISTORY_CAP)
219
267
  this.messages.shift();
@@ -35,16 +35,25 @@ options:
35
35
  --origin <url> RogerRat origin (default: https://rogerrat.chat)
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
- RR_MESSAGE, RR_FROM, RR_TO, RR_MSG_ID, RR_CHANNEL are set
38
+ RR_MESSAGE, RR_FROM, RR_TO, RR_MSG_ID, RR_CHANNEL,
39
+ RR_PRIORITY, RR_REPLIES are set
40
+ (RR_PRIORITY = "default" when omitted;
41
+ RR_REPLIES = tab-separated suggested replies, empty if none)
39
42
  --inbox <file> append each message to this file (parent dir created if
40
43
  missing). Format controlled by --format.
41
44
  --format <fmt> output format for stdout and --inbox lines:
42
- jsonl (default) — one JSON object per line: {id,from,to,text,at}
43
- text — one line per message: "[<from>] <text>"
44
- newlines in text collapsed to spaces.
45
+ jsonl (default) — one JSON object per line: {id,from,to,text,at,priority?,suggested_replies?}
46
+ text — one line per message:
47
+ "[<priority>] [<from>] <text> → [r1] [r2]"
48
+ where [<priority>] and → [r1] [r2] only appear
49
+ when set. Newlines in text collapsed to spaces.
45
50
  Use this when feeding a Monitor/tail consumer
46
51
  so each line is a human-readable notification
47
52
  with no parser needed in the watcher.
53
+ --min-priority <p> drop messages below this priority (min|low|default|high|urgent).
54
+ Filtered messages do NOT write to --inbox, fire --on-message,
55
+ or print to stdout. Use for park-style channels: stay
56
+ wake-able only on real signals, let chatter accumulate silently.
48
57
  --quiet suppress the default stdout dump of each message
49
58
 
50
59
  if neither --on-message nor --inbox is given, messages print to stdout (one
@@ -70,6 +79,13 @@ examples:
70
79
  rogerrat listen-here --channel ch1 --token t --session s \\
71
80
  --on-message 'claude -p "rogerrat msg from $RR_FROM: $RR_MESSAGE"'
72
81
  `;
82
+ const PRIORITY_RANK = {
83
+ min: 0,
84
+ low: 1,
85
+ default: 2,
86
+ high: 3,
87
+ urgent: 4,
88
+ };
73
89
  function parseFlags(argv) {
74
90
  let parsed;
75
91
  try {
@@ -84,6 +100,7 @@ function parseFlags(argv) {
84
100
  "on-message": { type: "string" },
85
101
  inbox: { type: "string" },
86
102
  format: { type: "string" },
103
+ "min-priority": { type: "string" },
87
104
  quiet: { type: "boolean" },
88
105
  help: { type: "boolean", short: "h" },
89
106
  },
@@ -116,6 +133,14 @@ function parseFlags(argv) {
116
133
  }
117
134
  format = parsed.values.format;
118
135
  }
136
+ let minPriority;
137
+ if (parsed.values["min-priority"] !== undefined) {
138
+ const v = parsed.values["min-priority"];
139
+ if (v !== "min" && v !== "low" && v !== "default" && v !== "high" && v !== "urgent") {
140
+ return { error: "--min-priority must be one of min|low|default|high|urgent" };
141
+ }
142
+ minPriority = v;
143
+ }
119
144
  return {
120
145
  channel,
121
146
  token,
@@ -126,6 +151,7 @@ function parseFlags(argv) {
126
151
  inbox: parsed.values.inbox,
127
152
  format,
128
153
  quiet: parsed.values.quiet === true,
154
+ minPriority,
129
155
  };
130
156
  }
131
157
  /** Yield one SSE block (the lines between two blank-line separators) at a time.
@@ -169,11 +195,27 @@ function formatLine(args, msg) {
169
195
  // one notification per message. Use a single space; the JSONL format is
170
196
  // available for callers that need lossless body content.
171
197
  const flat = msg.text.replace(/\r?\n/g, " ").trim();
172
- return `[${msg.from}] ${flat}`;
198
+ // Surface non-default priority as a leading tag so a Monitor tail of the
199
+ // inbox file can grep for urgency without parsing JSON. e.g. `[urgent] `
200
+ const prio = msg.priority && msg.priority !== "default" ? `[${msg.priority}] ` : "";
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}`;
173
207
  }
174
208
  return JSON.stringify(msg);
175
209
  }
176
210
  async function dispatch(args, msg) {
211
+ // --min-priority filter: drop messages below the threshold entirely (no
212
+ // inbox write, no hook spawn, no stdout). Missing priority counts as
213
+ // "default" (rank 2).
214
+ if (args.minPriority) {
215
+ const incomingRank = PRIORITY_RANK[msg.priority ?? "default"];
216
+ if (incomingRank < PRIORITY_RANK[args.minPriority])
217
+ return;
218
+ }
177
219
  const line = formatLine(args, msg);
178
220
  if (args.inbox) {
179
221
  const dir = dirname(args.inbox);
@@ -199,6 +241,10 @@ async function dispatch(args, msg) {
199
241
  RR_TO: msg.to,
200
242
  RR_MSG_ID: String(msg.id),
201
243
  RR_CHANNEL: args.channel,
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"),
202
248
  },
203
249
  });
204
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 } 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";
@@ -62,12 +62,17 @@ const CHANNEL_TOOLS = [
62
62
  },
63
63
  {
64
64
  name: "send",
65
- description: "Send a message to another agent on the channel by their callsign, or to 'all' to broadcast. Returns the message id.",
65
+ description: "Send a message to another agent on the channel by their callsign, or to 'all' to broadcast. Returns the message id. Optional `priority` (min|low|default|high|urgent) — receivers may wake immediately on high/urgent.",
66
66
  inputSchema: {
67
67
  type: "object",
68
68
  properties: {
69
69
  to: { type: "string", description: "Recipient callsign, or 'all' for broadcast." },
70
70
  message: { type: "string", description: "Message text. Max 8192 chars." },
71
+ priority: {
72
+ type: "string",
73
+ enum: ["min", "low", "default", "high", "urgent"],
74
+ description: "Optional urgency. Default = 'default'. Receivers interpret.",
75
+ },
71
76
  },
72
77
  required: ["to", "message"],
73
78
  },
@@ -164,12 +169,23 @@ const UNIFIED_TOOLS = [
164
169
  },
165
170
  {
166
171
  name: "send",
167
- 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'.",
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.",
168
173
  inputSchema: {
169
174
  type: "object",
170
175
  properties: {
171
176
  to: { type: "string", description: "Recipient: callsign, '#N' index, or 'all' for broadcast." },
172
177
  message: { type: "string", description: "Message text. Max 8192 chars." },
178
+ priority: {
179
+ type: "string",
180
+ enum: ["min", "low", "default", "high", "urgent"],
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
+ },
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
+ },
173
189
  },
174
190
  required: ["to", "message"],
175
191
  },
@@ -316,11 +332,15 @@ async function callChannelTool(channel, sessionId, name, args) {
316
332
  case "send": {
317
333
  const to = String(args.to ?? "");
318
334
  const message = String(args.message ?? args.text ?? "");
319
- const msg = channel.send(sessionId, to, message);
335
+ const priority = isPriority(args.priority) ? args.priority : undefined;
336
+ const suggestedReplies = validateSuggestedReplies(args.suggested_replies);
337
+ const msg = channel.send(sessionId, to, message, priority, suggestedReplies);
320
338
  statsRecordMessage();
321
339
  transcriptRecordMessage(channel.id, getChannelRetention(channel.id), msg);
322
340
  const queued = msg.to !== "all" && !channel.isCallsignOnline(msg.to);
323
- return textContent(`sent #${msg.id} to ${msg.to}${queued ? " (queued — recipient is offline, will be delivered when they rejoin)" : ""}`);
341
+ const prio = msg.priority ? ` [${msg.priority}]` : "";
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}`);
324
344
  }
325
345
  case "listen": {
326
346
  const seconds = typeof args.timeout_seconds === "number" ? args.timeout_seconds : 30;
@@ -598,11 +618,15 @@ async function callUnifiedTool(name, args, state, sessionId, publicOrigin, mode
598
618
  case "send": {
599
619
  const to = String(args.to ?? "");
600
620
  const message = String(args.message ?? args.text ?? "");
601
- const msg = channel.send(sessionId, to, message);
621
+ const priority = isPriority(args.priority) ? args.priority : undefined;
622
+ const suggestedReplies = validateSuggestedReplies(args.suggested_replies);
623
+ const msg = channel.send(sessionId, to, message, priority, suggestedReplies);
602
624
  statsRecordMessage();
603
625
  transcriptRecordMessage(channel.id, getChannelRetention(channel.id), msg);
604
626
  const queued = msg.to !== "all" && !channel.isCallsignOnline(msg.to);
605
- return textContent(`sent #${msg.id} to ${msg.to}${queued ? " (queued — recipient is offline, will be delivered when they rejoin)" : ""}`);
627
+ const prio = msg.priority ? ` [${msg.priority}]` : "";
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}`);
606
630
  }
607
631
  case "listen": {
608
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.18.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": [