@nordbyte/nordrelay 0.6.0 → 0.8.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/.env.example +52 -0
- package/README.md +171 -50
- package/dist/access-control.js +6 -1
- package/dist/activity-events.js +2 -2
- package/dist/adapter-conformance.js +61 -0
- package/dist/bot-preferences.js +1 -0
- package/dist/bot.js +95 -37
- package/dist/channel-adapter.js +44 -11
- package/dist/channel-command-catalog.js +94 -0
- package/dist/channel-command-core.js +60 -0
- package/dist/channel-command-service.js +230 -1
- package/dist/channel-mirror-registry.js +84 -0
- package/dist/channel-peer-prompt.js +95 -0
- package/dist/channel-prompt-engine.js +177 -0
- package/dist/channel-runtime.js +12 -5
- package/dist/channel-turn-lifecycle.js +73 -0
- package/dist/codex-state.js +114 -78
- package/dist/config-metadata.js +82 -8
- package/dist/config.js +79 -7
- package/dist/context-key.js +42 -0
- package/dist/discord-bot.js +173 -342
- package/dist/discord-command-surface.js +11 -73
- package/dist/index.js +29 -0
- package/dist/metrics.js +48 -0
- package/dist/peer-auth.js +85 -0
- package/dist/peer-client.js +288 -0
- package/dist/peer-context.js +21 -0
- package/dist/peer-identity.js +127 -0
- package/dist/peer-readiness.js +77 -0
- package/dist/peer-runtime-service.js +658 -0
- package/dist/peer-server.js +220 -0
- package/dist/peer-store.js +307 -0
- package/dist/peer-types.js +52 -0
- package/dist/relay-runtime-helpers.js +210 -0
- package/dist/relay-runtime.js +79 -274
- package/dist/remote-prompt.js +98 -0
- package/dist/settings-wizard-test.js +216 -0
- package/dist/slack-artifacts.js +165 -0
- package/dist/slack-bot.js +1461 -0
- package/dist/slack-channel-runtime.js +147 -0
- package/dist/slack-command-surface.js +46 -0
- package/dist/slack-diagnostics.js +116 -0
- package/dist/slack-rate-limit.js +139 -0
- package/dist/telegram-command-menu.js +3 -53
- package/dist/telegram-general-commands.js +14 -0
- package/dist/telegram-preference-commands.js +23 -127
- package/dist/user-management-crypto.js +38 -0
- package/dist/user-management-normalize.js +188 -0
- package/dist/user-management-types.js +1 -0
- package/dist/user-management.js +193 -196
- package/dist/web-api-contract.js +16 -0
- package/dist/web-dashboard-access-routes.js +62 -0
- package/dist/web-dashboard-assets.js +1 -0
- package/dist/web-dashboard-pages.js +26 -4
- package/dist/web-dashboard-peer-routes.js +225 -0
- package/dist/web-dashboard-ui.js +1 -0
- package/dist/web-dashboard.js +46 -0
- package/dist/web-state.js +2 -2
- package/dist/webui-assets/dashboard.css +193 -0
- package/dist/webui-assets/dashboard.js +870 -57
- package/package.json +5 -2
- package/plugins/nordrelay/scripts/nordrelay.mjs +468 -11
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import { listAgentAdapterDescriptors } from "./agent-adapter.js";
|
|
2
2
|
import { enabledAgents } from "./agent-factory.js";
|
|
3
|
+
import { formatQuietHours, isQuietNow, parseMirrorMode, parseNotifyMode, parseQuietHours, parseVoiceBackendPreference, } from "./bot-preferences.js";
|
|
3
4
|
import { logTailRequests, parseLogsCommand, renderAgentsAction, renderChannelsAction, renderLogTailsAction, } from "./channel-actions.js";
|
|
4
5
|
import { listChannelDescriptors } from "./channel-adapter.js";
|
|
6
|
+
import { friendlyErrorText } from "./error-messages.js";
|
|
5
7
|
import { escapeHTML } from "./format.js";
|
|
6
8
|
import { getConnectorHealth, getVersionChecks, readConnectorState, readFormattedLogTail, } from "./operations.js";
|
|
7
|
-
import {
|
|
9
|
+
import { PeerStore } from "./peer-store.js";
|
|
10
|
+
import { formatCliPathHTML, formatCliPathPlain, renderActivityTimeline, renderAuditEvents, renderProgressHTML, renderProgressPlain, parseToggle, renderVersionCheckHTML, renderVersionCheckPlain, } from "./bot-rendering.js";
|
|
8
11
|
import { renderSessionInfoHTML, renderSessionInfoPlain } from "./session-format.js";
|
|
12
|
+
import { getAvailableBackends } from "./voice.js";
|
|
9
13
|
export class ChannelCommandService {
|
|
10
14
|
config;
|
|
11
15
|
constructor(config) {
|
|
@@ -17,6 +21,61 @@ export class ChannelCommandService {
|
|
|
17
21
|
renderAgents(agentIds = enabledAgents(this.config)) {
|
|
18
22
|
return renderAgentsAction(listAgentAdapterDescriptors(), agentIds);
|
|
19
23
|
}
|
|
24
|
+
renderPeers() {
|
|
25
|
+
const peers = new PeerStore().listPublic();
|
|
26
|
+
if (peers.length === 0) {
|
|
27
|
+
return {
|
|
28
|
+
plain: "No NordRelay peers configured.",
|
|
29
|
+
html: "No NordRelay peers configured.",
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
const plain = peers.map((peer) => [
|
|
33
|
+
`${peer.name} (${peer.id}) ${peer.enabled ? "enabled" : "disabled"}`,
|
|
34
|
+
`URL: ${peer.url ?? "-"}`,
|
|
35
|
+
`Node: ${peer.nodeId}`,
|
|
36
|
+
`Scopes: ${peer.scopes.join(", ") || "-"}`,
|
|
37
|
+
peer.remoteStatus || peer.lastLatencyMs !== undefined ? `Health: ${peer.remoteStatus ?? "seen"}${peer.lastLatencyMs !== undefined ? ` / ${peer.lastLatencyMs}ms` : ""}${peer.remoteVersion ? ` / v${peer.remoteVersion}` : ""}` : "",
|
|
38
|
+
Object.keys(peer.workspaceAliases ?? {}).length > 0 ? `Aliases: ${Object.entries(peer.workspaceAliases).map(([alias, workspace]) => `${alias}=${workspace}`).join(", ")}` : "",
|
|
39
|
+
peer.lastSeenAt ? `Last seen: ${peer.lastSeenAt}` : "",
|
|
40
|
+
peer.lastError ? `Last error: ${peer.lastError}` : "",
|
|
41
|
+
].filter(Boolean).join("\n")).join("\n\n");
|
|
42
|
+
const html = peers.map((peer) => [
|
|
43
|
+
`<b>${escapeHTML(peer.name)} (${escapeHTML(peer.id)})</b> <code>${peer.enabled ? "enabled" : "disabled"}</code>`,
|
|
44
|
+
`<b>URL:</b> <code>${escapeHTML(peer.url ?? "-")}</code>`,
|
|
45
|
+
`<b>Node:</b> <code>${escapeHTML(peer.nodeId)}</code>`,
|
|
46
|
+
`<b>Scopes:</b> <code>${escapeHTML(peer.scopes.join(", ") || "-")}</code>`,
|
|
47
|
+
peer.remoteStatus || peer.lastLatencyMs !== undefined ? `<b>Health:</b> <code>${escapeHTML(`${peer.remoteStatus ?? "seen"}${peer.lastLatencyMs !== undefined ? ` / ${peer.lastLatencyMs}ms` : ""}${peer.remoteVersion ? ` / v${peer.remoteVersion}` : ""}`)}</code>` : "",
|
|
48
|
+
Object.keys(peer.workspaceAliases ?? {}).length > 0 ? `<b>Aliases:</b> <code>${escapeHTML(Object.entries(peer.workspaceAliases).map(([alias, workspace]) => `${alias}=${workspace}`).join(", "))}</code>` : "",
|
|
49
|
+
peer.lastSeenAt ? `<b>Last seen:</b> <code>${escapeHTML(peer.lastSeenAt)}</code>` : "",
|
|
50
|
+
peer.lastError ? `<b>Last error:</b> <code>${escapeHTML(peer.lastError)}</code>` : "",
|
|
51
|
+
].filter(Boolean).join("\n")).join("\n\n");
|
|
52
|
+
return { plain, html };
|
|
53
|
+
}
|
|
54
|
+
renderTargetPreference(options) {
|
|
55
|
+
const argument = options.argument.trim();
|
|
56
|
+
const peers = new PeerStore().listPublic().filter((peer) => peer.enabled && peer.url);
|
|
57
|
+
if (argument) {
|
|
58
|
+
const normalized = argument.toLowerCase();
|
|
59
|
+
if (normalized === "local") {
|
|
60
|
+
options.preferencesStore.update(options.contextKey, { targetPeerId: null });
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
const peer = peers.find((candidate) => candidate.id === argument || candidate.name.toLowerCase() === normalized || candidate.nodeId === argument);
|
|
64
|
+
if (!peer) {
|
|
65
|
+
return usageResponse("Unknown peer target. Use /target local or /target <peer-id>.");
|
|
66
|
+
}
|
|
67
|
+
options.preferencesStore.update(options.contextKey, { targetPeerId: peer.id });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const current = options.preferencesStore.get(options.contextKey).targetPeerId;
|
|
71
|
+
const currentPeer = current ? peers.find((peer) => peer.id === current) : null;
|
|
72
|
+
const target = currentPeer ? `${currentPeer.name} (${currentPeer.id})` : "local";
|
|
73
|
+
const available = peers.map((peer) => `${peer.id} ${peer.name}`).join("\n") || "No enabled outbound peers.";
|
|
74
|
+
return {
|
|
75
|
+
plain: [`Target: ${target}`, "", "Available peers:", available].join("\n"),
|
|
76
|
+
html: [`<b>Target:</b> <code>${escapeHTML(target)}</code>`, "", "<b>Available peers:</b>", `<code>${escapeHTML(available)}</code>`].join("\n"),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
20
79
|
async renderLogs(argument) {
|
|
21
80
|
const logRequest = parseLogsCommand(argument);
|
|
22
81
|
const logs = await Promise.all(logTailRequests(logRequest.target).map(async (request) => ({
|
|
@@ -101,6 +160,142 @@ export class ChannelCommandService {
|
|
|
101
160
|
renderAudit(events) {
|
|
102
161
|
return renderAuditEvents(events);
|
|
103
162
|
}
|
|
163
|
+
renderMirrorPreference(options) {
|
|
164
|
+
if (options.cliMirrorSupported === false) {
|
|
165
|
+
const text = `CLI mirroring is not supported for ${options.agentLabel ?? "this agent"} yet.`;
|
|
166
|
+
return { plain: text, html: escapeHTML(text) };
|
|
167
|
+
}
|
|
168
|
+
const argument = options.argument.trim();
|
|
169
|
+
if (argument) {
|
|
170
|
+
const normalized = argument.toLowerCase();
|
|
171
|
+
if (!["off", "status", "final", "full"].includes(normalized)) {
|
|
172
|
+
return usageResponse("Usage: /mirror [off|status|final|full]");
|
|
173
|
+
}
|
|
174
|
+
options.preferencesStore.update(options.contextKey, {
|
|
175
|
+
mirrorMode: parseMirrorMode(argument, this.defaultMirrorMode(options.source)),
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
const mode = this.effectiveMirrorMode(options.source, options.contextKey, options.preferencesStore);
|
|
179
|
+
const minInterval = options.source === "telegram"
|
|
180
|
+
? this.config.telegramMirrorMinUpdateMs
|
|
181
|
+
: options.source === "discord"
|
|
182
|
+
? this.config.discordMirrorMinUpdateMs
|
|
183
|
+
: this.config.slackMirrorMinUpdateMs;
|
|
184
|
+
return {
|
|
185
|
+
plain: [
|
|
186
|
+
`CLI mirroring: ${mode}`,
|
|
187
|
+
`Minimum update interval: ${minInterval} ms`,
|
|
188
|
+
"Modes: off, status, final, full",
|
|
189
|
+
].join("\n"),
|
|
190
|
+
html: [
|
|
191
|
+
`<b>CLI mirroring:</b> <code>${escapeHTML(mode)}</code>`,
|
|
192
|
+
`<b>Minimum update interval:</b> <code>${minInterval} ms</code>`,
|
|
193
|
+
"<b>Modes:</b> <code>off</code>, <code>status</code>, <code>final</code>, <code>full</code>",
|
|
194
|
+
].join("\n"),
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
renderNotifyPreference(options) {
|
|
198
|
+
const argument = options.argument.trim();
|
|
199
|
+
if (argument) {
|
|
200
|
+
const quietMatch = argument.match(/^quiet\s+(.+)$/i);
|
|
201
|
+
if (quietMatch) {
|
|
202
|
+
try {
|
|
203
|
+
const quietHours = quietMatch[1].toLowerCase() === "off" ? null : parseQuietHours(quietMatch[1]);
|
|
204
|
+
options.preferencesStore.update(options.contextKey, { quietHours });
|
|
205
|
+
}
|
|
206
|
+
catch (error) {
|
|
207
|
+
const text = `Invalid quiet hours: ${friendlyErrorText(error)}`;
|
|
208
|
+
return { plain: text, html: escapeHTML(text) };
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
else {
|
|
212
|
+
const normalized = argument.toLowerCase();
|
|
213
|
+
if (!["off", "minimal", "all"].includes(normalized)) {
|
|
214
|
+
return usageResponse("Usage: /notify [off|minimal|all] or /notify quiet HH-HH");
|
|
215
|
+
}
|
|
216
|
+
options.preferencesStore.update(options.contextKey, {
|
|
217
|
+
notifyMode: parseNotifyMode(argument, this.defaultNotifyMode(options.source)),
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
const mode = this.effectiveNotifyMode(options.source, options.contextKey, options.preferencesStore);
|
|
222
|
+
const quietHours = this.effectiveQuietHours(options.source, options.contextKey, options.preferencesStore);
|
|
223
|
+
return {
|
|
224
|
+
plain: [
|
|
225
|
+
`Notifications: ${mode}`,
|
|
226
|
+
`Quiet hours: ${formatQuietHours(quietHours)}`,
|
|
227
|
+
`Currently quiet: ${isQuietNow(quietHours) ? "yes" : "no"}`,
|
|
228
|
+
].join("\n"),
|
|
229
|
+
html: [
|
|
230
|
+
`<b>Notifications:</b> <code>${escapeHTML(mode)}</code>`,
|
|
231
|
+
`<b>Quiet hours:</b> <code>${escapeHTML(formatQuietHours(quietHours))}</code>`,
|
|
232
|
+
`<b>Currently quiet:</b> <code>${isQuietNow(quietHours) ? "yes" : "no"}</code>`,
|
|
233
|
+
].join("\n"),
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
async renderVoicePreference(options) {
|
|
237
|
+
const argument = options.argument.trim();
|
|
238
|
+
if (argument) {
|
|
239
|
+
const parts = argument.split(/\s+/);
|
|
240
|
+
const key = parts[0]?.toLowerCase();
|
|
241
|
+
const value = parts.slice(1).join(" ").trim();
|
|
242
|
+
if (key === "backend" && value) {
|
|
243
|
+
const normalized = value.toLowerCase();
|
|
244
|
+
if (!["auto", "parakeet", "faster-whisper", "openai"].includes(normalized)) {
|
|
245
|
+
return usageResponse("Usage: /voice backend auto|parakeet|faster-whisper|openai");
|
|
246
|
+
}
|
|
247
|
+
options.preferencesStore.update(options.contextKey, { voiceBackend: parseVoiceBackendPreference(value) });
|
|
248
|
+
}
|
|
249
|
+
else if (key === "language") {
|
|
250
|
+
options.preferencesStore.update(options.contextKey, { voiceLanguage: value && value.toLowerCase() !== "auto" ? value : null });
|
|
251
|
+
}
|
|
252
|
+
else if (key === "transcribe_only" || key === "transcribe-only") {
|
|
253
|
+
const enabled = parseToggle(value);
|
|
254
|
+
if (enabled === undefined) {
|
|
255
|
+
return usageResponse("Usage: /voice transcribe_only on|off");
|
|
256
|
+
}
|
|
257
|
+
options.preferencesStore.update(options.contextKey, { voiceTranscribeOnly: enabled });
|
|
258
|
+
}
|
|
259
|
+
else {
|
|
260
|
+
return usageResponse("Usage: /voice, /voice backend auto|parakeet|faster-whisper|openai, /voice language auto|language-code, /voice transcribe_only on|off");
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
const backends = await getAvailableBackends().catch(() => []);
|
|
264
|
+
if (backends.length === 0) {
|
|
265
|
+
const plain = [
|
|
266
|
+
"Voice transcription is not available.",
|
|
267
|
+
"",
|
|
268
|
+
"Install faster-whisper + ffmpeg, install parakeet-coreml on macOS Apple Silicon, or set OPENAI_API_KEY.",
|
|
269
|
+
"Cloud transcription uses OPENAI_API_KEY, not CODEX_API_KEY.",
|
|
270
|
+
].join("\n");
|
|
271
|
+
const html = [
|
|
272
|
+
"<b>Voice transcription is not available.</b>",
|
|
273
|
+
"",
|
|
274
|
+
"Install <code>faster-whisper</code> + ffmpeg, install <code>parakeet-coreml</code> on macOS Apple Silicon, or set <code>OPENAI_API_KEY</code>.",
|
|
275
|
+
"<i>Cloud transcription uses OPENAI_API_KEY, not CODEX_API_KEY.</i>",
|
|
276
|
+
].join("\n");
|
|
277
|
+
return { plain, html };
|
|
278
|
+
}
|
|
279
|
+
const prefs = options.preferencesStore.get(options.contextKey);
|
|
280
|
+
const backendPreference = prefs.voiceBackend ?? this.config.voicePreferredBackend;
|
|
281
|
+
const language = prefs.voiceLanguage === undefined ? this.config.voiceDefaultLanguage ?? null : prefs.voiceLanguage;
|
|
282
|
+
const transcribeOnly = prefs.voiceTranscribeOnly ?? this.config.voiceTranscribeOnly;
|
|
283
|
+
const joined = backends.join(" + ");
|
|
284
|
+
return {
|
|
285
|
+
plain: [
|
|
286
|
+
`Voice backends: ${joined}`,
|
|
287
|
+
`Preferred backend: ${backendPreference}`,
|
|
288
|
+
`Language: ${language ?? "auto"}`,
|
|
289
|
+
`Transcribe only: ${transcribeOnly ? "on" : "off"}`,
|
|
290
|
+
].join("\n"),
|
|
291
|
+
html: [
|
|
292
|
+
`<b>Voice backends:</b> <code>${escapeHTML(joined)}</code>`,
|
|
293
|
+
`<b>Preferred backend:</b> <code>${escapeHTML(backendPreference)}</code>`,
|
|
294
|
+
`<b>Language:</b> <code>${escapeHTML(language ?? "auto")}</code>`,
|
|
295
|
+
`<b>Transcribe only:</b> <code>${transcribeOnly ? "on" : "off"}</code>`,
|
|
296
|
+
].join("\n"),
|
|
297
|
+
};
|
|
298
|
+
}
|
|
104
299
|
renderWorkspaces(info, workspaces) {
|
|
105
300
|
const unique = [...new Set(workspaces)].filter(Boolean);
|
|
106
301
|
const rows = unique.length > 0
|
|
@@ -142,6 +337,37 @@ export class ChannelCommandService {
|
|
|
142
337
|
].join("\n"),
|
|
143
338
|
};
|
|
144
339
|
}
|
|
340
|
+
defaultMirrorMode(source) {
|
|
341
|
+
return source === "telegram"
|
|
342
|
+
? this.config.telegramMirrorMode
|
|
343
|
+
: source === "discord"
|
|
344
|
+
? this.config.discordMirrorMode
|
|
345
|
+
: this.config.slackMirrorMode;
|
|
346
|
+
}
|
|
347
|
+
defaultNotifyMode(source) {
|
|
348
|
+
return source === "telegram"
|
|
349
|
+
? this.config.telegramNotifyMode
|
|
350
|
+
: source === "discord"
|
|
351
|
+
? this.config.discordNotifyMode
|
|
352
|
+
: this.config.slackNotifyMode;
|
|
353
|
+
}
|
|
354
|
+
defaultQuietHours(source) {
|
|
355
|
+
return source === "telegram"
|
|
356
|
+
? this.config.telegramQuietHours
|
|
357
|
+
: source === "discord"
|
|
358
|
+
? this.config.discordQuietHours
|
|
359
|
+
: this.config.slackQuietHours;
|
|
360
|
+
}
|
|
361
|
+
effectiveMirrorMode(source, contextKey, preferencesStore) {
|
|
362
|
+
return preferencesStore.get(contextKey).mirrorMode ?? this.defaultMirrorMode(source);
|
|
363
|
+
}
|
|
364
|
+
effectiveNotifyMode(source, contextKey, preferencesStore) {
|
|
365
|
+
return preferencesStore.get(contextKey).notifyMode ?? this.defaultNotifyMode(source);
|
|
366
|
+
}
|
|
367
|
+
effectiveQuietHours(source, contextKey, preferencesStore) {
|
|
368
|
+
const prefs = preferencesStore.get(contextKey);
|
|
369
|
+
return prefs.quietHours === undefined ? this.defaultQuietHours(source) : prefs.quietHours;
|
|
370
|
+
}
|
|
145
371
|
}
|
|
146
372
|
export function cliPathOptions(config) {
|
|
147
373
|
return {
|
|
@@ -154,3 +380,6 @@ export function cliPathOptions(config) {
|
|
|
154
380
|
function shellEscape(value) {
|
|
155
381
|
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
156
382
|
}
|
|
383
|
+
function usageResponse(text) {
|
|
384
|
+
return { plain: text, html: escapeHTML(text) };
|
|
385
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { channelIdForContextKey } from "./context-key.js";
|
|
2
|
+
export class ChannelMirrorRegistry {
|
|
3
|
+
config;
|
|
4
|
+
promptStore;
|
|
5
|
+
states = new Map();
|
|
6
|
+
constructor(config, promptStore) {
|
|
7
|
+
this.config = config;
|
|
8
|
+
this.promptStore = promptStore;
|
|
9
|
+
}
|
|
10
|
+
activeMirrorsForThread(agentId, threadId, knownContexts, preferences) {
|
|
11
|
+
const mirrors = [];
|
|
12
|
+
const seen = new Set();
|
|
13
|
+
for (const meta of knownContexts) {
|
|
14
|
+
const metaAgentId = meta.agentId ?? this.config.defaultAgent;
|
|
15
|
+
if (meta.threadId !== threadId || metaAgentId !== agentId) {
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
18
|
+
const source = activeSessionSourceForContextKey(meta.contextKey);
|
|
19
|
+
if (!isMirrorChannelSource(source) || seen.has(meta.contextKey)) {
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
const mode = this.effectiveMirrorMode(meta.contextKey, source, preferences);
|
|
23
|
+
if (mode === "off") {
|
|
24
|
+
this.states.delete(this.stateKey(source, meta.contextKey, agentId, threadId));
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
seen.add(meta.contextKey);
|
|
28
|
+
const mirror = {
|
|
29
|
+
source,
|
|
30
|
+
contextKey: meta.contextKey,
|
|
31
|
+
mode,
|
|
32
|
+
queueLength: this.promptStore.list(meta.contextKey).length,
|
|
33
|
+
queuePaused: this.promptStore.isPaused(meta.contextKey),
|
|
34
|
+
};
|
|
35
|
+
mirrors.push(mirror);
|
|
36
|
+
this.states.set(this.stateKey(source, meta.contextKey, agentId, threadId), {
|
|
37
|
+
...mirror,
|
|
38
|
+
agentId,
|
|
39
|
+
threadId,
|
|
40
|
+
updatedAt: new Date().toISOString(),
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
return mirrors;
|
|
44
|
+
}
|
|
45
|
+
queueLengthForExternalSource(sourceContextKey, mirrors) {
|
|
46
|
+
return mirrors.reduce((sum, mirror) => sum + mirror.queueLength, this.promptStore.list(sourceContextKey).length);
|
|
47
|
+
}
|
|
48
|
+
queuePausedForExternalSource(sourceContextKey, mirrors) {
|
|
49
|
+
return mirrors.some((mirror) => mirror.queuePaused) || this.promptStore.isPaused(sourceContextKey);
|
|
50
|
+
}
|
|
51
|
+
effectiveMirrorMode(contextKey, source, preferences) {
|
|
52
|
+
const configured = source === "telegram"
|
|
53
|
+
? this.config.telegramMirrorMode
|
|
54
|
+
: source === "discord"
|
|
55
|
+
? this.config.discordMirrorMode
|
|
56
|
+
: this.config.slackMirrorMode;
|
|
57
|
+
return preferences.get(contextKey).mirrorMode ?? configured;
|
|
58
|
+
}
|
|
59
|
+
snapshot() {
|
|
60
|
+
return [...this.states.values()].sort((left, right) => Date.parse(right.updatedAt) - Date.parse(left.updatedAt));
|
|
61
|
+
}
|
|
62
|
+
stateKey(source, contextKey, agentId, threadId) {
|
|
63
|
+
return `${source}:${contextKey}:${agentId}:${threadId}`;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
export function activeSessionSourceForContextKey(contextKey) {
|
|
67
|
+
const channelId = channelIdForContextKey(contextKey);
|
|
68
|
+
if (channelId === "telegram") {
|
|
69
|
+
return "telegram";
|
|
70
|
+
}
|
|
71
|
+
if (channelId === "discord") {
|
|
72
|
+
return "discord";
|
|
73
|
+
}
|
|
74
|
+
if (channelId === "slack") {
|
|
75
|
+
return "slack";
|
|
76
|
+
}
|
|
77
|
+
if (channelId === "web") {
|
|
78
|
+
return "web";
|
|
79
|
+
}
|
|
80
|
+
return "cli";
|
|
81
|
+
}
|
|
82
|
+
export function isMirrorChannelSource(source) {
|
|
83
|
+
return source === "telegram" || source === "discord" || source === "slack";
|
|
84
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { friendlyErrorText } from "./error-messages.js";
|
|
2
|
+
import { RemoteRelayClient } from "./peer-client.js";
|
|
3
|
+
import { peerPromptProxyPayload } from "./remote-prompt.js";
|
|
4
|
+
export async function runChannelPeerPrompt(options) {
|
|
5
|
+
if (!options.targetPeerId) {
|
|
6
|
+
return false;
|
|
7
|
+
}
|
|
8
|
+
const client = options.remoteClient ?? new RemoteRelayClient();
|
|
9
|
+
let responseMessageId;
|
|
10
|
+
let accumulated = "";
|
|
11
|
+
let lastEditAt = 0;
|
|
12
|
+
let completed = false;
|
|
13
|
+
let closeSubscription = () => { };
|
|
14
|
+
const typing = setInterval(() => {
|
|
15
|
+
void options.sendTyping().catch(() => { });
|
|
16
|
+
}, options.typingIntervalMs);
|
|
17
|
+
typing.unref?.();
|
|
18
|
+
void options.sendTyping().catch(() => { });
|
|
19
|
+
const flush = async (force = false) => {
|
|
20
|
+
if (!accumulated.trim()) {
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
const now = Date.now();
|
|
24
|
+
if (!force && now - lastEditAt < options.editMinIntervalMs) {
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
if (responseMessageId === undefined) {
|
|
28
|
+
responseMessageId = await options.sendResponse(accumulated);
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
await options.editResponse(responseMessageId, accumulated);
|
|
32
|
+
}
|
|
33
|
+
lastEditAt = now;
|
|
34
|
+
};
|
|
35
|
+
const done = new Promise((resolve) => {
|
|
36
|
+
const timeout = setTimeout(resolve, 30 * 60 * 1000);
|
|
37
|
+
timeout.unref?.();
|
|
38
|
+
let subscription;
|
|
39
|
+
const finish = () => {
|
|
40
|
+
clearTimeout(timeout);
|
|
41
|
+
subscription?.close();
|
|
42
|
+
resolve();
|
|
43
|
+
};
|
|
44
|
+
subscription = client.subscribe(options.targetPeerId, (event) => {
|
|
45
|
+
if (event.type === "turn_start") {
|
|
46
|
+
void options.sendTurnStart(event.prompt).catch(() => { });
|
|
47
|
+
}
|
|
48
|
+
else if (event.type === "text_delta") {
|
|
49
|
+
accumulated += event.delta;
|
|
50
|
+
void flush(false).catch(() => { });
|
|
51
|
+
}
|
|
52
|
+
else if (event.type === "tool_start") {
|
|
53
|
+
void options.sendToolStart(event.toolName).catch(() => { });
|
|
54
|
+
}
|
|
55
|
+
else if (event.type === "turn_complete") {
|
|
56
|
+
completed = true;
|
|
57
|
+
finish();
|
|
58
|
+
}
|
|
59
|
+
else if (event.type === "turn_error") {
|
|
60
|
+
accumulated += `\n\nError: ${event.error}`;
|
|
61
|
+
completed = true;
|
|
62
|
+
finish();
|
|
63
|
+
}
|
|
64
|
+
}, (error) => {
|
|
65
|
+
accumulated += `\n\nRemote event stream failed: ${error.message}`;
|
|
66
|
+
finish();
|
|
67
|
+
}, options.contextKey);
|
|
68
|
+
closeSubscription = () => subscription?.close();
|
|
69
|
+
});
|
|
70
|
+
try {
|
|
71
|
+
const result = await client.webProxy(options.targetPeerId, await peerPromptProxyPayload(options.prompt), options.prompt.activityActor, options.contextKey);
|
|
72
|
+
if (isQueuedRemoteResult(result)) {
|
|
73
|
+
closeSubscription();
|
|
74
|
+
await options.sendQueued(String(result.queueId ?? ""));
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
await done;
|
|
78
|
+
await flush(true);
|
|
79
|
+
if (!accumulated.trim() && completed) {
|
|
80
|
+
await options.sendCompleted();
|
|
81
|
+
}
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
await options.sendFailure(friendlyErrorText(error));
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
finally {
|
|
89
|
+
clearInterval(typing);
|
|
90
|
+
closeSubscription();
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
function isQueuedRemoteResult(value) {
|
|
94
|
+
return Boolean(value && typeof value === "object" && "queued" in value && value.queued);
|
|
95
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { createChannelTurnLifecycle, createChannelTypingLoop } from "./channel-turn-lifecycle.js";
|
|
2
|
+
export function createChannelPromptEngine(options) {
|
|
3
|
+
const lifecycle = createChannelTurnLifecycle(options.promptDescription);
|
|
4
|
+
const { progress, startedAt, turnId } = lifecycle;
|
|
5
|
+
let accumulated = "";
|
|
6
|
+
let responseMessageId;
|
|
7
|
+
let planMessageId;
|
|
8
|
+
let flushTimer;
|
|
9
|
+
const typingLoop = createChannelTypingLoop({
|
|
10
|
+
intervalMs: options.typingIntervalMs,
|
|
11
|
+
sendTyping: () => options.runtime.sendTyping(options.context),
|
|
12
|
+
});
|
|
13
|
+
let lastEditAt = 0;
|
|
14
|
+
let running = false;
|
|
15
|
+
let finalized = false;
|
|
16
|
+
const buttons = options.abortAction
|
|
17
|
+
? [[{ label: "Abort", action: options.abortAction }]]
|
|
18
|
+
: undefined;
|
|
19
|
+
const clearFlushTimer = () => {
|
|
20
|
+
if (flushTimer) {
|
|
21
|
+
clearTimeout(flushTimer);
|
|
22
|
+
flushTimer = undefined;
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
const ensureResponse = async () => {
|
|
26
|
+
if (responseMessageId) {
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
const preview = options.trimMessage(accumulated || "Working...");
|
|
30
|
+
const sent = await options.runtime.sendMessage(options.context, {
|
|
31
|
+
text: preview,
|
|
32
|
+
fallbackText: preview,
|
|
33
|
+
buttons,
|
|
34
|
+
});
|
|
35
|
+
responseMessageId = sent.messageId;
|
|
36
|
+
options.onResponseMessage?.(responseMessageId);
|
|
37
|
+
lastEditAt = Date.now();
|
|
38
|
+
};
|
|
39
|
+
const flushResponse = async (force = false) => {
|
|
40
|
+
if (!accumulated.trim()) {
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
await ensureResponse();
|
|
44
|
+
if (!responseMessageId) {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
const now = Date.now();
|
|
48
|
+
if (!force && now - lastEditAt < options.editDebounceMs) {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const rendered = options.trimMessage(accumulated);
|
|
52
|
+
await options.runtime.editMessage(options.context, responseMessageId, {
|
|
53
|
+
text: rendered,
|
|
54
|
+
fallbackText: rendered,
|
|
55
|
+
buttons,
|
|
56
|
+
});
|
|
57
|
+
lastEditAt = Date.now();
|
|
58
|
+
};
|
|
59
|
+
const scheduleFlush = () => {
|
|
60
|
+
if (flushTimer || !running) {
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
const delay = Math.max(0, options.editDebounceMs - (Date.now() - lastEditAt));
|
|
64
|
+
flushTimer = setTimeout(() => {
|
|
65
|
+
flushTimer = undefined;
|
|
66
|
+
void flushResponse().catch((error) => console.error(`Failed to edit ${options.logPrefix} response:`, error));
|
|
67
|
+
}, delay);
|
|
68
|
+
flushTimer.unref?.();
|
|
69
|
+
};
|
|
70
|
+
const stop = () => {
|
|
71
|
+
running = false;
|
|
72
|
+
typingLoop.stop();
|
|
73
|
+
clearFlushTimer();
|
|
74
|
+
};
|
|
75
|
+
const finalize = async () => {
|
|
76
|
+
if (finalized) {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
finalized = true;
|
|
80
|
+
lifecycle.recordCompleted();
|
|
81
|
+
stop();
|
|
82
|
+
const finalText = accumulated.trim() || "Done.";
|
|
83
|
+
const chunks = options.splitMessage(finalText);
|
|
84
|
+
if (responseMessageId) {
|
|
85
|
+
const [first, ...rest] = chunks;
|
|
86
|
+
await options.runtime.editMessage(options.context, responseMessageId, {
|
|
87
|
+
text: first ?? "Done.",
|
|
88
|
+
fallbackText: first ?? "Done.",
|
|
89
|
+
});
|
|
90
|
+
for (const chunk of rest) {
|
|
91
|
+
await options.runtime.sendMessage(options.context, { text: chunk, fallbackText: chunk });
|
|
92
|
+
}
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
for (const chunk of chunks) {
|
|
96
|
+
await options.runtime.sendMessage(options.context, { text: chunk, fallbackText: chunk });
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
const fail = async (text) => {
|
|
100
|
+
finalized = true;
|
|
101
|
+
lifecycle.recordFailed(text);
|
|
102
|
+
stop();
|
|
103
|
+
const rendered = options.trimMessage(text);
|
|
104
|
+
if (responseMessageId) {
|
|
105
|
+
await options.runtime.editMessage(options.context, responseMessageId, {
|
|
106
|
+
text: rendered,
|
|
107
|
+
fallbackText: rendered,
|
|
108
|
+
}).catch(() => { });
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
await options.runtime.sendMessage(options.context, {
|
|
112
|
+
text: rendered,
|
|
113
|
+
fallbackText: rendered,
|
|
114
|
+
}).catch(() => { });
|
|
115
|
+
};
|
|
116
|
+
const callbacks = {
|
|
117
|
+
onTextDelta: (delta) => {
|
|
118
|
+
accumulated += delta;
|
|
119
|
+
lifecycle.recordTextDelta(delta.length);
|
|
120
|
+
void ensureResponse()
|
|
121
|
+
.then(() => scheduleFlush())
|
|
122
|
+
.catch((error) => console.error(`Failed to send ${options.logPrefix} response:`, error));
|
|
123
|
+
},
|
|
124
|
+
onToolStart: (toolName) => {
|
|
125
|
+
lifecycle.recordToolStart(toolName);
|
|
126
|
+
options.onToolStart?.(toolName);
|
|
127
|
+
if (options.toolVerbosity === "all") {
|
|
128
|
+
const text = `Tool started: ${toolName}`;
|
|
129
|
+
void options.runtime.sendMessage(options.context, { text, fallbackText: text }).catch(() => { });
|
|
130
|
+
}
|
|
131
|
+
},
|
|
132
|
+
onToolUpdate: () => {
|
|
133
|
+
lifecycle.recordToolUpdate();
|
|
134
|
+
},
|
|
135
|
+
onToolEnd: (_toolCallId, isError) => {
|
|
136
|
+
lifecycle.recordToolEnd();
|
|
137
|
+
options.onToolEnd?.(isError);
|
|
138
|
+
},
|
|
139
|
+
onTodoUpdate: (items) => {
|
|
140
|
+
lifecycle.touch();
|
|
141
|
+
const text = [
|
|
142
|
+
"Plan:",
|
|
143
|
+
...items.map((item) => `${item.completed ? "[x]" : "[ ]"} ${item.text}`),
|
|
144
|
+
].join("\n");
|
|
145
|
+
if (!planMessageId) {
|
|
146
|
+
void options.runtime.sendMessage(options.context, { text, fallbackText: text }).then((result) => {
|
|
147
|
+
planMessageId = result.messageId;
|
|
148
|
+
}).catch(() => { });
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
void options.runtime.editMessage(options.context, planMessageId, { text, fallbackText: text }).catch(() => { });
|
|
152
|
+
}
|
|
153
|
+
},
|
|
154
|
+
onTurnComplete: () => { },
|
|
155
|
+
onAgentEnd: () => {
|
|
156
|
+
lifecycle.recordCompleted();
|
|
157
|
+
void finalize().catch((error) => console.error(`Failed to finalize ${options.logPrefix} response:`, error));
|
|
158
|
+
},
|
|
159
|
+
};
|
|
160
|
+
return {
|
|
161
|
+
turnId,
|
|
162
|
+
startedAt,
|
|
163
|
+
progress,
|
|
164
|
+
callbacks,
|
|
165
|
+
accumulatedText: () => accumulated,
|
|
166
|
+
start: () => {
|
|
167
|
+
if (running) {
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
running = true;
|
|
171
|
+
typingLoop.start();
|
|
172
|
+
},
|
|
173
|
+
stop,
|
|
174
|
+
finalize,
|
|
175
|
+
fail,
|
|
176
|
+
};
|
|
177
|
+
}
|
package/dist/channel-runtime.js
CHANGED
|
@@ -1,13 +1,19 @@
|
|
|
1
1
|
export class ChannelCommandRouter {
|
|
2
2
|
handlers = new Map();
|
|
3
3
|
command(name, handler) {
|
|
4
|
-
const normalized =
|
|
4
|
+
const normalized = normalizeChannelCommandName(name);
|
|
5
5
|
if (!normalized) {
|
|
6
6
|
throw new Error("Channel command name is required.");
|
|
7
7
|
}
|
|
8
8
|
this.handlers.set(normalized, handler);
|
|
9
9
|
return this;
|
|
10
10
|
}
|
|
11
|
+
commands(names, handler) {
|
|
12
|
+
for (const name of names) {
|
|
13
|
+
this.command(name, handler);
|
|
14
|
+
}
|
|
15
|
+
return this;
|
|
16
|
+
}
|
|
11
17
|
async dispatch(message) {
|
|
12
18
|
const parsed = parseChannelCommand(message.text ?? "");
|
|
13
19
|
if (!parsed) {
|
|
@@ -36,13 +42,14 @@ export async function deliverChannelAction(runtime, context, response) {
|
|
|
36
42
|
buttons: response.buttons,
|
|
37
43
|
});
|
|
38
44
|
}
|
|
39
|
-
export function parseChannelCommand(text) {
|
|
40
|
-
const
|
|
45
|
+
export function parseChannelCommand(text, options = {}) {
|
|
46
|
+
const mention = options.allowBotMention === false ? "" : "(?:@\\w+)?";
|
|
47
|
+
const match = text.trimStart().match(new RegExp(`^/([a-zA-Z0-9_-]+)${mention}(?:\\s+([\\s\\S]*))?$`));
|
|
41
48
|
if (!match?.[1]) {
|
|
42
49
|
return null;
|
|
43
50
|
}
|
|
44
51
|
return {
|
|
45
|
-
command:
|
|
52
|
+
command: normalizeChannelCommandName(match[1]),
|
|
46
53
|
argument: match[2]?.trim() ?? "",
|
|
47
54
|
};
|
|
48
55
|
}
|
|
@@ -84,6 +91,6 @@ export class InMemoryChannelRuntime {
|
|
|
84
91
|
return { messageId };
|
|
85
92
|
}
|
|
86
93
|
}
|
|
87
|
-
function
|
|
94
|
+
export function normalizeChannelCommandName(name) {
|
|
88
95
|
return name.trim().replace(/^\//, "").toLowerCase();
|
|
89
96
|
}
|