@nordbyte/nordrelay 0.4.0 → 0.5.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.
@@ -0,0 +1,372 @@
1
+ import { formatAgentFeatureSummaryHTML, formatAgentFeatureSummaryPlain } from "./agent-feature-matrix.js";
2
+ import { totalArtifactSize } from "./artifacts.js";
3
+ import { escapeHTML } from "./format.js";
4
+ import { getAgentUpdateLogPath, getUpdateLogPath } from "./operations.js";
5
+ import { formatFileSize } from "./session-format.js";
6
+ export function renderChannelsAction(descriptors) {
7
+ const plain = [
8
+ "Channel adapters:",
9
+ ...descriptors.map((descriptor) => {
10
+ const status = descriptor.status === "available" ? "available" : "planned";
11
+ return `${descriptor.label}: ${status} · ${descriptor.capabilities.join(", ")}`;
12
+ }),
13
+ ].join("\n");
14
+ const html = [
15
+ "<b>Channel adapters:</b>",
16
+ ...descriptors.map((descriptor) => {
17
+ const statusIcon = descriptor.status === "available" ? "✅" : "🟡";
18
+ const notes = descriptor.notes ? `\n ${escapeHTML(descriptor.notes)}` : "";
19
+ return `${statusIcon} <b>${escapeHTML(descriptor.label)}</b> <code>${escapeHTML(descriptor.status)}</code>\n <code>${escapeHTML(descriptor.capabilities.join(", "))}</code>${notes}`;
20
+ }),
21
+ ].join("\n");
22
+ return { plain, html };
23
+ }
24
+ export function renderAgentsAction(descriptors, enabledAgents) {
25
+ const enabled = new Set(enabledAgents);
26
+ const plain = [
27
+ "Agent adapters:",
28
+ ...descriptors.flatMap((descriptor) => [
29
+ `${descriptor.label}: ${descriptor.status}${descriptor.status === "available" ? ` · ${enabled.has(descriptor.id) ? "enabled" : "disabled"}` : ""}`,
30
+ ...formatAgentFeatureSummaryPlain(descriptor.capabilities).map((line) => ` ${line}`),
31
+ ]),
32
+ ].join("\n");
33
+ const html = [
34
+ "<b>Agent adapters:</b>",
35
+ ...descriptors.map((descriptor) => {
36
+ const status = descriptor.status === "available" ? `${enabled.has(descriptor.id) ? "enabled" : "disabled"}` : "planned";
37
+ const notes = descriptor.notes ? `\n ${escapeHTML(descriptor.notes)}` : "";
38
+ return [
39
+ `${descriptor.status === "available" ? "✅" : "🟡"} <b>${escapeHTML(descriptor.label)}</b> <code>${escapeHTML(status)}</code>${notes}`,
40
+ ...formatAgentFeatureSummaryHTML(descriptor.capabilities).map((line) => ` ${line}`),
41
+ ].join("\n");
42
+ }),
43
+ ].join("\n");
44
+ return { plain, html };
45
+ }
46
+ export function renderAgentUpdatePickerAction(descriptors) {
47
+ const available = descriptors.filter((descriptor) => descriptor.status === "available");
48
+ const buttons = available.map((descriptor) => [{ label: `Update ${descriptor.label}`, action: `agent-update:start:${descriptor.id}` }]);
49
+ buttons.push([{ label: "Show update jobs", action: "agent-update:jobs" }]);
50
+ return {
51
+ plain: [
52
+ "Agent updates:",
53
+ ...available.map((descriptor) => `${descriptor.label}: /update ${descriptor.id}`),
54
+ "",
55
+ "Use /update jobs to list running and recent agent updates.",
56
+ ].join("\n"),
57
+ html: [
58
+ "<b>Agent updates:</b>",
59
+ ...available.map((descriptor) => `<b>${escapeHTML(descriptor.label)}:</b> <code>/update ${escapeHTML(descriptor.id)}</code>`),
60
+ "",
61
+ "Use <code>/update jobs</code> to list running and recent agent updates.",
62
+ ].join("\n"),
63
+ buttons,
64
+ };
65
+ }
66
+ export function parseAgentUpdateId(value) {
67
+ const normalized = value?.toLowerCase();
68
+ if (!normalized) {
69
+ return null;
70
+ }
71
+ if (normalized === "claude") {
72
+ return "claude-code";
73
+ }
74
+ return ["codex", "pi", "hermes", "openclaw", "claude-code"].includes(normalized)
75
+ ? normalized
76
+ : null;
77
+ }
78
+ export function renderAgentUpdateJobsAction(jobs) {
79
+ if (jobs.length === 0) {
80
+ return {
81
+ plain: "No agent update jobs yet. Use /update agents to start one.",
82
+ html: "No agent update jobs yet. Use <code>/update agents</code> to start one.",
83
+ };
84
+ }
85
+ const limited = jobs.slice(0, 10);
86
+ return {
87
+ plain: [
88
+ "Agent update jobs:",
89
+ ...limited.map((job) => `${job.id}: ${job.agentLabel} · ${job.status} · ${formatLocalDateTime(new Date(job.updatedAt))}`),
90
+ "",
91
+ "Use /update log <id>, /update cancel <id>, or /update input <id> <text>.",
92
+ ].join("\n"),
93
+ html: [
94
+ "<b>Agent update jobs:</b>",
95
+ ...limited.map((job) => `<code>${escapeHTML(job.id)}</code> ${escapeHTML(job.agentLabel)} · <b>${escapeHTML(job.status)}</b> · <code>${escapeHTML(formatLocalDateTime(new Date(job.updatedAt)))}</code>`),
96
+ "",
97
+ "Use <code>/update log &lt;id&gt;</code>, <code>/update cancel &lt;id&gt;</code>, or <code>/update input &lt;id&gt; &lt;text&gt;</code>.",
98
+ ].join("\n"),
99
+ };
100
+ }
101
+ export function renderAgentUpdateJobAction(job) {
102
+ const command = [job.command, ...job.args].join(" ");
103
+ const inputLine = job.canInput
104
+ ? `If the updater asks a question, reply with /update input ${job.id} <text>.`
105
+ : "This update job is no longer accepting input.";
106
+ const tail = trimLine(job.outputTail || "(waiting for output)", 1200);
107
+ return {
108
+ plain: [
109
+ `${job.agentLabel} update ${job.status}.`,
110
+ `ID: ${job.id}`,
111
+ `Method: ${job.method}`,
112
+ `Command: ${command}`,
113
+ `Started: ${formatLocalDateTime(new Date(job.startedAt))}`,
114
+ job.finishedAt ? `Finished: ${formatLocalDateTime(new Date(job.finishedAt))}` : undefined,
115
+ job.error ? `Error: ${job.error}` : undefined,
116
+ `Log: ${job.logPath}`,
117
+ `Agent update log: ${getAgentUpdateLogPath()}`,
118
+ inputLine,
119
+ "",
120
+ tail,
121
+ ].filter(Boolean).join("\n"),
122
+ html: [
123
+ `<b>${escapeHTML(job.agentLabel)} update ${escapeHTML(job.status)}.</b>`,
124
+ `<b>ID:</b> <code>${escapeHTML(job.id)}</code>`,
125
+ `<b>Method:</b> <code>${escapeHTML(job.method)}</code>`,
126
+ `<b>Command:</b> <code>${escapeHTML(command)}</code>`,
127
+ `<b>Started:</b> <code>${escapeHTML(formatLocalDateTime(new Date(job.startedAt)))}</code>`,
128
+ job.finishedAt ? `<b>Finished:</b> <code>${escapeHTML(formatLocalDateTime(new Date(job.finishedAt)))}</code>` : undefined,
129
+ job.error ? `<b>Error:</b> ${escapeHTML(job.error)}` : undefined,
130
+ `<b>Log:</b> <code>${escapeHTML(job.logPath)}</code>`,
131
+ `<b>Agent update log:</b> <code>${escapeHTML(getAgentUpdateLogPath())}</code>`,
132
+ escapeHTML(inputLine),
133
+ "",
134
+ `<pre>${escapeHTML(tail)}</pre>`,
135
+ ].filter(Boolean).join("\n"),
136
+ buttons: [
137
+ [
138
+ { label: "Full log", action: `agent-update:log:${job.id}` },
139
+ ...(job.canInput ? [{ label: "Cancel", action: `agent-update:cancel:${job.id}` }] : []),
140
+ ],
141
+ ],
142
+ };
143
+ }
144
+ export function renderAgentUpdateLogAction(result) {
145
+ const tail = trimLine(result.plain || "(empty)", 3000);
146
+ return {
147
+ plain: [
148
+ `${result.job.agentLabel} update log`,
149
+ `ID: ${result.job.id}`,
150
+ `Status: ${result.job.status}`,
151
+ `File: ${result.job.logPath}`,
152
+ "",
153
+ tail,
154
+ ].join("\n"),
155
+ html: [
156
+ `<b>${escapeHTML(result.job.agentLabel)} update log</b>`,
157
+ `<b>ID:</b> <code>${escapeHTML(result.job.id)}</code>`,
158
+ `<b>Status:</b> <code>${escapeHTML(result.job.status)}</code>`,
159
+ `<b>File:</b> <code>${escapeHTML(result.job.logPath)}</code>`,
160
+ "",
161
+ `<pre>${escapeHTML(tail)}</pre>`,
162
+ ].join("\n"),
163
+ };
164
+ }
165
+ export function renderSelfUpdateStartedAction(update) {
166
+ return {
167
+ plain: [
168
+ "Update started.",
169
+ `Method: ${update.method}`,
170
+ update.summary,
171
+ `Source: ${update.sourceRoot}`,
172
+ `Log: ${update.logPath}`,
173
+ "Use /logs update after the restart or inspect update.log on the host.",
174
+ "Use /update agents for agent CLI updates.",
175
+ ].join("\n"),
176
+ html: [
177
+ "<b>Update started.</b>",
178
+ `<b>Method:</b> <code>${escapeHTML(update.method)}</code>`,
179
+ escapeHTML(update.summary),
180
+ `<b>Source:</b> <code>${escapeHTML(update.sourceRoot)}</code>`,
181
+ `<b>Log:</b> <code>${escapeHTML(update.logPath)}</code>`,
182
+ `Use <code>/logs update</code> after the restart or inspect <code>${escapeHTML(getUpdateLogPath())}</code> on the host.`,
183
+ "Use <code>/update agents</code> for agent CLI updates.",
184
+ ].join("\n"),
185
+ };
186
+ }
187
+ export function parseLogsCommand(argument) {
188
+ const tokens = argument.split(/\s+/).filter(Boolean);
189
+ let target = "connector";
190
+ let lines = 80;
191
+ for (const token of tokens) {
192
+ const normalized = token.toLowerCase();
193
+ if (normalized === "connector" || normalized === "main") {
194
+ target = "connector";
195
+ continue;
196
+ }
197
+ if (normalized === "update" || normalized === "self-update" || normalized === "self") {
198
+ target = "update";
199
+ continue;
200
+ }
201
+ if (normalized === "agent" || normalized === "agents" || normalized === "agent-update" || normalized === "agent-updates") {
202
+ target = "agent-updates";
203
+ continue;
204
+ }
205
+ if (normalized === "all") {
206
+ target = "all";
207
+ continue;
208
+ }
209
+ const parsedLines = Number.parseInt(token, 10);
210
+ if (!Number.isNaN(parsedLines)) {
211
+ lines = parsedLines;
212
+ }
213
+ }
214
+ return { target, lines };
215
+ }
216
+ export function logTailRequests(target) {
217
+ if (target === "all") {
218
+ return [
219
+ { title: "Connector" },
220
+ { title: "Update", path: getUpdateLogPath() },
221
+ { title: "Agent updates", path: getAgentUpdateLogPath() },
222
+ ];
223
+ }
224
+ return [{ title: logTargetTitle(target), path: logTargetPath(target) }];
225
+ }
226
+ export function renderLogTailsAction(logs) {
227
+ return {
228
+ plain: logs.map(({ title, tail }) => renderLogTailPlain(title, tail)).join("\n\n"),
229
+ html: logs.map(({ title, tail }) => renderLogTailHTML(title, tail)).join("\n\n"),
230
+ };
231
+ }
232
+ export function renderArtifactReportsAction(reports) {
233
+ const lines = reports.slice(0, 5).map((report, index) => {
234
+ const size = formatFileSize(totalArtifactSize(report.artifacts));
235
+ const skipped = report.skippedCount > 0 ? `, ${report.skippedCount} skipped` : "";
236
+ return `${index + 1}. ${report.turnId} · ${formatRelativeTime(report.updatedAt)} · ${report.artifacts.length} file${report.artifacts.length === 1 ? "" : "s"} · ${size}${skipped}`;
237
+ });
238
+ const usage = "Tap an action below, or use /artifacts latest, /artifacts zip latest, /artifacts images, /artifacts docs, /artifacts search <text>, or /artifacts delete <turn-id>.";
239
+ return {
240
+ plain: ["Recent artifacts:", ...lines, "", usage].join("\n"),
241
+ html: ["<b>Recent artifacts:</b>", ...lines.map(escapeHTML), "", escapeHTML(usage)].join("\n"),
242
+ };
243
+ }
244
+ export function renderQueueListAction(queue, paused) {
245
+ if (queue.length === 0) {
246
+ return {
247
+ plain: paused ? "Queue is empty and paused." : "Queue is empty.",
248
+ html: escapeHTML(paused ? "Queue is empty and paused." : "Queue is empty."),
249
+ };
250
+ }
251
+ const lines = queue.map((item, index) => {
252
+ const age = formatRelativeTime(new Date(item.createdAt));
253
+ const attempts = item.attempts && item.attempts > 0 ? ` · attempts ${item.attempts}` : "";
254
+ const error = item.lastError ? ` · last error: ${trimLine(item.lastError, 80)}` : "";
255
+ const scheduled = item.notBefore && item.notBefore > Date.now()
256
+ ? `scheduled ${formatLocalDateTime(new Date(item.notBefore))}`
257
+ : index === 0 ? "next" : `after ${index} queued item${index === 1 ? "" : "s"}`;
258
+ return `${index + 1}. ${item.id} · ${age} · ${scheduled}${attempts}${error} · ${item.description}`;
259
+ });
260
+ return {
261
+ plain: [paused ? "Queued prompts (paused):" : "Queued prompts:", ...lines].join("\n"),
262
+ html: [paused ? "<b>Queued prompts:</b> <code>paused</code>" : "<b>Queued prompts:</b>", ...lines.map(escapeHTML)].join("\n"),
263
+ };
264
+ }
265
+ export function renderQueuedPromptDetailAction(item) {
266
+ const lines = [
267
+ "Queued prompt:",
268
+ `ID: ${item.id}`,
269
+ `Created: ${formatLocalDateTime(new Date(item.createdAt))}`,
270
+ item.notBefore ? `Scheduled: ${formatLocalDateTime(new Date(item.notBefore))}` : undefined,
271
+ `Attempts: ${item.attempts ?? 0}`,
272
+ item.lastError ? `Last error: ${item.lastError}` : undefined,
273
+ `Description: ${item.description}`,
274
+ ].filter((line) => Boolean(line));
275
+ return {
276
+ plain: lines.join("\n"),
277
+ html: [
278
+ "<b>Queued prompt:</b>",
279
+ `<b>ID:</b> <code>${escapeHTML(item.id)}</code>`,
280
+ `<b>Created:</b> <code>${escapeHTML(formatLocalDateTime(new Date(item.createdAt)))}</code>`,
281
+ item.notBefore ? `<b>Scheduled:</b> <code>${escapeHTML(formatLocalDateTime(new Date(item.notBefore)))}</code>` : undefined,
282
+ `<b>Attempts:</b> <code>${item.attempts ?? 0}</code>`,
283
+ item.lastError ? `<b>Last error:</b> ${escapeHTML(item.lastError)}` : undefined,
284
+ `<b>Description:</b> ${escapeHTML(item.description)}`,
285
+ ].filter((line) => Boolean(line)).join("\n"),
286
+ };
287
+ }
288
+ function logTargetTitle(target) {
289
+ if (target === "update") {
290
+ return "Update";
291
+ }
292
+ if (target === "agent-updates") {
293
+ return "Agent updates";
294
+ }
295
+ return "Connector";
296
+ }
297
+ function logTargetPath(target) {
298
+ if (target === "update") {
299
+ return getUpdateLogPath();
300
+ }
301
+ if (target === "agent-updates") {
302
+ return getAgentUpdateLogPath();
303
+ }
304
+ return undefined;
305
+ }
306
+ function renderLogTailPlain(title, tail) {
307
+ return [
308
+ `${title} log tail`,
309
+ `File: ${tail.filePath}`,
310
+ `Updated: ${tail.updatedAt ? formatLocalDateTime(tail.updatedAt) : "-"}`,
311
+ `Lines: ${tail.lineCount}/${tail.requestedLines}`,
312
+ "",
313
+ tail.plain || "(empty)",
314
+ ].join("\n");
315
+ }
316
+ function renderLogTailHTML(title, tail) {
317
+ const body = tail.plain
318
+ ? tail.plain.split("\n").map(renderLogLineHTML).join("\n")
319
+ : "<code>(empty)</code>";
320
+ return [
321
+ `<b>${escapeHTML(title)} log tail</b>`,
322
+ `<b>File:</b> <code>${escapeHTML(tail.filePath)}</code>`,
323
+ `<b>Updated:</b> <code>${escapeHTML(tail.updatedAt ? formatLocalDateTime(tail.updatedAt) : "-")}</code>`,
324
+ `<b>Lines:</b> <code>${tail.lineCount}/${tail.requestedLines}</code>`,
325
+ "",
326
+ body,
327
+ ].join("\n");
328
+ }
329
+ function renderLogLineHTML(line) {
330
+ const structured = line.match(/^(?<timestamp>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}|unknown time\s*)\s+(?<level>INFO|WARN|ERROR)\s+(?<message>.*)$/);
331
+ if (structured?.groups) {
332
+ const level = structured.groups.level;
333
+ const levelHtml = level === "INFO" ? escapeHTML(level) : `<b>${escapeHTML(level)}</b>`;
334
+ return [
335
+ `<code>${escapeHTML(structured.groups.timestamp.trim())}</code>`,
336
+ levelHtml,
337
+ escapeHTML(structured.groups.message),
338
+ ].join(" ");
339
+ }
340
+ return escapeHTML(line);
341
+ }
342
+ function formatLocalDateTime(date) {
343
+ if (Number.isNaN(date.getTime())) {
344
+ return "-";
345
+ }
346
+ return [
347
+ `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`,
348
+ `${pad2(date.getHours())}:${pad2(date.getMinutes())}:${pad2(date.getSeconds())}`,
349
+ ].join(" ");
350
+ }
351
+ function formatRelativeTime(date) {
352
+ const seconds = Math.max(0, Math.round((Date.now() - date.getTime()) / 1000));
353
+ if (seconds < 60) {
354
+ return `${seconds}s ago`;
355
+ }
356
+ const minutes = Math.round(seconds / 60);
357
+ if (minutes < 60) {
358
+ return `${minutes}m ago`;
359
+ }
360
+ const hours = Math.round(minutes / 60);
361
+ if (hours < 24) {
362
+ return `${hours}h ago`;
363
+ }
364
+ return `${Math.round(hours / 24)}d ago`;
365
+ }
366
+ function trimLine(text, maxLength) {
367
+ const normalized = text.replace(/\s+/g, " ").trim();
368
+ return normalized.length > maxLength ? `${normalized.slice(0, Math.max(0, maxLength - 3))}...` : normalized;
369
+ }
370
+ function pad2(value) {
371
+ return String(value).padStart(2, "0");
372
+ }
@@ -0,0 +1,89 @@
1
+ export class ChannelCommandRouter {
2
+ handlers = new Map();
3
+ command(name, handler) {
4
+ const normalized = normalizeCommandName(name);
5
+ if (!normalized) {
6
+ throw new Error("Channel command name is required.");
7
+ }
8
+ this.handlers.set(normalized, handler);
9
+ return this;
10
+ }
11
+ async dispatch(message) {
12
+ const parsed = parseChannelCommand(message.text ?? "");
13
+ if (!parsed) {
14
+ return { matched: false };
15
+ }
16
+ const handler = this.handlers.get(parsed.command);
17
+ if (!handler) {
18
+ return { matched: false, command: parsed.command };
19
+ }
20
+ const response = await handler({
21
+ ...message,
22
+ text: parsed.argument,
23
+ });
24
+ return {
25
+ matched: true,
26
+ command: parsed.command,
27
+ response: response ?? undefined,
28
+ };
29
+ }
30
+ }
31
+ export async function deliverChannelAction(runtime, context, response) {
32
+ return runtime.sendMessage(context, {
33
+ text: response.html,
34
+ fallbackText: response.plain,
35
+ parseMode: "html",
36
+ buttons: response.buttons,
37
+ });
38
+ }
39
+ export function parseChannelCommand(text) {
40
+ const match = text.trimStart().match(/^\/([a-zA-Z0-9_-]+)(?:@\w+)?(?:\s+([\s\S]*))?$/);
41
+ if (!match?.[1]) {
42
+ return null;
43
+ }
44
+ return {
45
+ command: normalizeCommandName(match[1]),
46
+ argument: match[2]?.trim() ?? "",
47
+ };
48
+ }
49
+ export class InMemoryChannelRuntime {
50
+ descriptor;
51
+ capabilities;
52
+ id;
53
+ label;
54
+ sentMessages = [];
55
+ editedMessages = [];
56
+ typingContexts = [];
57
+ sentFiles = [];
58
+ constructor(descriptor) {
59
+ this.descriptor = descriptor;
60
+ this.id = descriptor.id;
61
+ this.label = descriptor.label;
62
+ this.capabilities = new Set(descriptor.capabilities);
63
+ }
64
+ describe() {
65
+ return {
66
+ ...this.descriptor,
67
+ capabilities: [...this.descriptor.capabilities],
68
+ };
69
+ }
70
+ async sendMessage(context, message) {
71
+ const messageId = `${this.id}-message-${this.sentMessages.length + 1}`;
72
+ this.sentMessages.push({ context, message, messageId });
73
+ return { messageId };
74
+ }
75
+ async editMessage(context, messageId, message) {
76
+ this.editedMessages.push({ context, messageId, message });
77
+ }
78
+ async sendTyping(context) {
79
+ this.typingContexts.push(context);
80
+ }
81
+ async sendFile(context, file) {
82
+ const messageId = `${this.id}-file-${this.sentFiles.length + 1}`;
83
+ this.sentFiles.push({ context, file, messageId });
84
+ return { messageId };
85
+ }
86
+ }
87
+ function normalizeCommandName(name) {
88
+ return name.trim().replace(/^\//, "").toLowerCase();
89
+ }