@nordbyte/nordrelay 0.7.0 → 0.8.1
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 +35 -0
- package/README.md +118 -49
- package/dist/activity-events.js +2 -2
- package/dist/adapter-conformance.js +61 -0
- package/dist/bot.js +18 -31
- package/dist/channel-adapter.js +33 -6
- package/dist/channel-command-catalog.js +6 -0
- package/dist/channel-command-core.js +60 -0
- package/dist/channel-command-service.js +20 -4
- package/dist/channel-mirror-registry.js +9 -2
- package/dist/channel-prompt-engine.js +177 -0
- package/dist/channel-turn-lifecycle.js +73 -0
- package/dist/config-metadata.js +67 -8
- package/dist/config.js +48 -1
- package/dist/context-key.js +32 -0
- package/dist/discord-bot.js +99 -327
- package/dist/index.js +9 -0
- package/dist/metrics.js +2 -0
- package/dist/peer-client.js +90 -2
- package/dist/peer-readiness.js +77 -0
- package/dist/peer-runtime-service.js +22 -0
- package/dist/peer-server.js +20 -4
- package/dist/peer-store.js +17 -2
- package/dist/relay-runtime-helpers.js +3 -1
- package/dist/relay-runtime.js +7 -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/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 +8 -0
- package/dist/web-dashboard-access-routes.js +62 -0
- package/dist/web-dashboard-assets.js +1 -0
- package/dist/web-dashboard-pages.js +14 -4
- package/dist/web-dashboard-peer-routes.js +32 -11
- package/dist/web-dashboard.js +34 -0
- package/dist/web-state.js +2 -2
- package/dist/webui-assets/dashboard.css +193 -0
- package/dist/webui-assets/dashboard.js +546 -145
- package/package.json +3 -1
- package/plugins/nordrelay/scripts/nordrelay.mjs +105 -11
package/dist/bot.js
CHANGED
|
@@ -14,6 +14,7 @@ import { renderAgentUpdateJobAction } from "./channel-actions.js";
|
|
|
14
14
|
import { ChannelCommandService } from "./channel-command-service.js";
|
|
15
15
|
import { runChannelPeerPrompt } from "./channel-peer-prompt.js";
|
|
16
16
|
import { deliverChannelAction } from "./channel-runtime.js";
|
|
17
|
+
import { createChannelTurnLifecycle, createChannelTypingLoop } from "./channel-turn-lifecycle.js";
|
|
17
18
|
import { agentLabel, agentReasoningLabel, agentReasoningOptions, } from "./agent.js";
|
|
18
19
|
import { getExternalActivityForSession, getExternalSnapshotForSession, } from "./agent-activity.js";
|
|
19
20
|
import { checkAuthStatus, clearAuthCache, startLogin as startCodexLogin, startLogout as startCodexLogout } from "./codex-auth.js";
|
|
@@ -1118,14 +1119,8 @@ export function createBot(config, registry) {
|
|
|
1118
1119
|
}
|
|
1119
1120
|
const busyState = getBusyState(contextKey);
|
|
1120
1121
|
busyState.processing = true;
|
|
1121
|
-
const
|
|
1122
|
-
|
|
1123
|
-
promptDescription: envelope.description,
|
|
1124
|
-
startedAt: Date.now(),
|
|
1125
|
-
updatedAt: Date.now(),
|
|
1126
|
-
toolCounts: new Map(),
|
|
1127
|
-
textCharacters: 0,
|
|
1128
|
-
};
|
|
1122
|
+
const turnLifecycle = createChannelTurnLifecycle(envelope.description);
|
|
1123
|
+
const progress = turnLifecycle.progress;
|
|
1129
1124
|
turnProgress.set(contextKey, progress);
|
|
1130
1125
|
const abortKeyboard = new InlineKeyboard().text("⏹ Abort", `agent_abort:${contextKey}`);
|
|
1131
1126
|
const toolVerbosity = config.toolVerbosity;
|
|
@@ -1147,12 +1142,13 @@ export function createBot(config, registry) {
|
|
|
1147
1142
|
let promptStartedAt;
|
|
1148
1143
|
const toolActivityNames = new Map();
|
|
1149
1144
|
const toolActivityStartedAt = new Map();
|
|
1150
|
-
const
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1145
|
+
const typingLoop = createChannelTypingLoop({
|
|
1146
|
+
intervalMs: TYPING_INTERVAL_MS,
|
|
1147
|
+
sendTyping: () => sendChatActionSafe(bot.api, chatId, "typing", messageThreadId),
|
|
1148
|
+
});
|
|
1149
|
+
typingLoop.start();
|
|
1154
1150
|
const stopTyping = () => {
|
|
1155
|
-
|
|
1151
|
+
typingLoop.stop();
|
|
1156
1152
|
};
|
|
1157
1153
|
const clearFlushTimer = () => {
|
|
1158
1154
|
if (flushTimer) {
|
|
@@ -1303,6 +1299,7 @@ export function createBot(config, registry) {
|
|
|
1303
1299
|
return;
|
|
1304
1300
|
}
|
|
1305
1301
|
finalized = true;
|
|
1302
|
+
turnLifecycle.recordCompleted();
|
|
1306
1303
|
stopTyping();
|
|
1307
1304
|
clearFlushTimer();
|
|
1308
1305
|
if (responseMessagePromise) {
|
|
@@ -1331,8 +1328,7 @@ export function createBot(config, registry) {
|
|
|
1331
1328
|
const callbacks = {
|
|
1332
1329
|
onTextDelta: (delta) => {
|
|
1333
1330
|
accumulatedText += delta;
|
|
1334
|
-
|
|
1335
|
-
progress.updatedAt = Date.now();
|
|
1331
|
+
turnLifecycle.recordTextDelta(delta.length);
|
|
1336
1332
|
if (!responseMessageId) {
|
|
1337
1333
|
void ensureResponseMessage()
|
|
1338
1334
|
.then(() => {
|
|
@@ -1346,10 +1342,7 @@ export function createBot(config, registry) {
|
|
|
1346
1342
|
scheduleFlush();
|
|
1347
1343
|
},
|
|
1348
1344
|
onToolStart: (toolName, toolCallId) => {
|
|
1349
|
-
|
|
1350
|
-
progress.lastTool = toolName;
|
|
1351
|
-
progress.updatedAt = Date.now();
|
|
1352
|
-
progress.toolCounts.set(toolName, (progress.toolCounts.get(toolName) ?? 0) + 1);
|
|
1345
|
+
turnLifecycle.recordToolStart(toolName);
|
|
1353
1346
|
toolActivityNames.set(toolCallId, toolName);
|
|
1354
1347
|
toolActivityStartedAt.set(toolCallId, Date.now());
|
|
1355
1348
|
appendTelegramActivity(ctx, contextKey, session, {
|
|
@@ -1393,7 +1386,7 @@ export function createBot(config, registry) {
|
|
|
1393
1386
|
});
|
|
1394
1387
|
},
|
|
1395
1388
|
onToolUpdate: (toolCallId, partialResult) => {
|
|
1396
|
-
|
|
1389
|
+
turnLifecycle.recordToolUpdate();
|
|
1397
1390
|
if (toolVerbosity === "none" || toolVerbosity === "summary") {
|
|
1398
1391
|
return;
|
|
1399
1392
|
}
|
|
@@ -1404,8 +1397,7 @@ export function createBot(config, registry) {
|
|
|
1404
1397
|
state.partialResult = appendWithCap(state.partialResult, partialResult, TOOL_OUTPUT_PREVIEW_LIMIT);
|
|
1405
1398
|
},
|
|
1406
1399
|
onToolEnd: (toolCallId, isError) => {
|
|
1407
|
-
|
|
1408
|
-
progress.updatedAt = Date.now();
|
|
1400
|
+
turnLifecycle.recordToolEnd();
|
|
1409
1401
|
const activityToolName = toolActivityNames.get(toolCallId) ?? "tool";
|
|
1410
1402
|
const activityStartedAt = toolActivityStartedAt.get(toolCallId);
|
|
1411
1403
|
appendTelegramActivity(ctx, contextKey, session, {
|
|
@@ -1450,7 +1442,7 @@ export function createBot(config, registry) {
|
|
|
1450
1442
|
});
|
|
1451
1443
|
},
|
|
1452
1444
|
onTodoUpdate: (items) => {
|
|
1453
|
-
|
|
1445
|
+
turnLifecycle.touch();
|
|
1454
1446
|
if (toolVerbosity === "none") {
|
|
1455
1447
|
return;
|
|
1456
1448
|
}
|
|
@@ -1482,7 +1474,7 @@ export function createBot(config, registry) {
|
|
|
1482
1474
|
},
|
|
1483
1475
|
onTurnComplete: (usage) => {
|
|
1484
1476
|
lastTurnUsage = usage;
|
|
1485
|
-
|
|
1477
|
+
turnLifecycle.touch();
|
|
1486
1478
|
},
|
|
1487
1479
|
onAgentEnd: () => {
|
|
1488
1480
|
void finalizeResponse().catch((error) => {
|
|
@@ -1597,9 +1589,7 @@ export function createBot(config, registry) {
|
|
|
1597
1589
|
await pruneArtifacts(session.getInfo().workspace);
|
|
1598
1590
|
}
|
|
1599
1591
|
}
|
|
1600
|
-
|
|
1601
|
-
progress.completedAt = Date.now();
|
|
1602
|
-
progress.updatedAt = progress.completedAt;
|
|
1592
|
+
turnLifecycle.recordCompleted();
|
|
1603
1593
|
auditContext(ctx, contextKey, session, {
|
|
1604
1594
|
action: "prompt_completed",
|
|
1605
1595
|
status: "ok",
|
|
@@ -1614,8 +1604,7 @@ export function createBot(config, registry) {
|
|
|
1614
1604
|
});
|
|
1615
1605
|
}
|
|
1616
1606
|
catch (error) {
|
|
1617
|
-
|
|
1618
|
-
progress.error = friendlyErrorText(error);
|
|
1607
|
+
turnLifecycle.recordFailed(friendlyErrorText(error));
|
|
1619
1608
|
auditContext(ctx, contextKey, session, {
|
|
1620
1609
|
action: "prompt_failed",
|
|
1621
1610
|
status: "failed",
|
|
@@ -1632,8 +1621,6 @@ export function createBot(config, registry) {
|
|
|
1632
1621
|
durationMs: Date.now() - promptStartedAt,
|
|
1633
1622
|
});
|
|
1634
1623
|
}
|
|
1635
|
-
progress.completedAt = Date.now();
|
|
1636
|
-
progress.updatedAt = progress.completedAt;
|
|
1637
1624
|
stopTyping();
|
|
1638
1625
|
clearFlushTimer();
|
|
1639
1626
|
if (responseMessagePromise) {
|
package/dist/channel-adapter.js
CHANGED
|
@@ -19,6 +19,17 @@ const DISCORD_CAPABILITIES = [
|
|
|
19
19
|
"voice",
|
|
20
20
|
"topics",
|
|
21
21
|
];
|
|
22
|
+
const SLACK_CAPABILITIES = [
|
|
23
|
+
"text",
|
|
24
|
+
"streaming-edits",
|
|
25
|
+
"typing",
|
|
26
|
+
"inline-buttons",
|
|
27
|
+
"files",
|
|
28
|
+
"photos",
|
|
29
|
+
"voice",
|
|
30
|
+
"topics",
|
|
31
|
+
"webhooks",
|
|
32
|
+
];
|
|
22
33
|
const PLANNED_CHANNELS = [
|
|
23
34
|
{
|
|
24
35
|
id: "whatsapp",
|
|
@@ -27,12 +38,6 @@ const PLANNED_CHANNELS = [
|
|
|
27
38
|
status: "planned",
|
|
28
39
|
notes: "Requires a WhatsApp Business provider integration.",
|
|
29
40
|
},
|
|
30
|
-
{
|
|
31
|
-
id: "slack",
|
|
32
|
-
label: "Slack",
|
|
33
|
-
capabilities: ["text", "streaming-edits", "typing", "inline-buttons", "files"],
|
|
34
|
-
status: "planned",
|
|
35
|
-
},
|
|
36
41
|
{
|
|
37
42
|
id: "matrix",
|
|
38
43
|
label: "Matrix",
|
|
@@ -82,10 +87,32 @@ export class DiscordChannelAdapter {
|
|
|
82
87
|
};
|
|
83
88
|
}
|
|
84
89
|
}
|
|
90
|
+
export class SlackChannelAdapter {
|
|
91
|
+
id = "slack";
|
|
92
|
+
label = "Slack";
|
|
93
|
+
capabilities = new Set(SLACK_CAPABILITIES);
|
|
94
|
+
describe() {
|
|
95
|
+
const requested = process.env.SLACK_ENABLED === "true";
|
|
96
|
+
const enabled = requested && Boolean(process.env.SLACK_BOT_TOKEN) && Boolean(process.env.SLACK_APP_TOKEN);
|
|
97
|
+
return {
|
|
98
|
+
id: this.id,
|
|
99
|
+
label: this.label,
|
|
100
|
+
capabilities: [...this.capabilities],
|
|
101
|
+
status: "available",
|
|
102
|
+
enabled,
|
|
103
|
+
notes: enabled
|
|
104
|
+
? "Slack bot runtime is enabled."
|
|
105
|
+
: requested
|
|
106
|
+
? "Slack bot runtime is disabled because SLACK_BOT_TOKEN or SLACK_APP_TOKEN is missing."
|
|
107
|
+
: "Enable with SLACK_ENABLED=true, SLACK_BOT_TOKEN, and SLACK_APP_TOKEN.",
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
}
|
|
85
111
|
export function listChannelDescriptors() {
|
|
86
112
|
return [
|
|
87
113
|
new TelegramChannelAdapter().describe(),
|
|
88
114
|
new DiscordChannelAdapter().describe(),
|
|
115
|
+
new SlackChannelAdapter().describe(),
|
|
89
116
|
...PLANNED_CHANNELS,
|
|
90
117
|
];
|
|
91
118
|
}
|
|
@@ -86,3 +86,9 @@ export function discordHelpCommandList() {
|
|
|
86
86
|
.map((entry) => `/${entry.name}`)
|
|
87
87
|
.join(", ");
|
|
88
88
|
}
|
|
89
|
+
export function slackHelpCommandList() {
|
|
90
|
+
return CHANNEL_COMMANDS
|
|
91
|
+
.filter((entry) => entry.slack !== false && !["start", "help", "prompt"].includes(entry.name))
|
|
92
|
+
.map((entry) => `/${entry.name}`)
|
|
93
|
+
.join(", ");
|
|
94
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { CHANNEL_COMMANDS } from "./channel-command-catalog.js";
|
|
2
|
+
import { normalizeChannelCommandName } from "./channel-runtime.js";
|
|
3
|
+
export function createSharedChannelCommandDispatcher(input) {
|
|
4
|
+
const handlers = new Map();
|
|
5
|
+
for (const binding of input.bindings) {
|
|
6
|
+
for (const name of binding.names) {
|
|
7
|
+
const normalized = normalizeChannelCommandName(name);
|
|
8
|
+
if (!normalized) {
|
|
9
|
+
throw new Error("Channel command name is required.");
|
|
10
|
+
}
|
|
11
|
+
if (handlers.has(normalized)) {
|
|
12
|
+
throw new Error(`Duplicate ${input.transport} command binding: ${normalized}`);
|
|
13
|
+
}
|
|
14
|
+
handlers.set(normalized, binding.handler);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
return {
|
|
18
|
+
transport: input.transport,
|
|
19
|
+
commandNames: [...handlers.keys()].sort(),
|
|
20
|
+
async dispatch(request, command, argument) {
|
|
21
|
+
const normalized = normalizeChannelCommandName(command);
|
|
22
|
+
const handler = handlers.get(normalized);
|
|
23
|
+
if (!handler) {
|
|
24
|
+
return { matched: false, command: normalized };
|
|
25
|
+
}
|
|
26
|
+
await handler(request, argument, normalized);
|
|
27
|
+
return { matched: true, command: normalized };
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
export function channelCatalogCommandNames(transport) {
|
|
32
|
+
return CHANNEL_COMMANDS
|
|
33
|
+
.filter((entry) => {
|
|
34
|
+
if (transport === "telegram")
|
|
35
|
+
return entry.telegram !== false;
|
|
36
|
+
if (transport === "discord")
|
|
37
|
+
return entry.discord !== false;
|
|
38
|
+
return entry.slack !== false;
|
|
39
|
+
})
|
|
40
|
+
.map((entry) => normalizeChannelCommandName(entry.name))
|
|
41
|
+
.sort();
|
|
42
|
+
}
|
|
43
|
+
export function channelCommandCoverage(input) {
|
|
44
|
+
const advertised = new Set(channelCatalogCommandNames(input.transport));
|
|
45
|
+
const implemented = new Set([...input.implemented].map(normalizeChannelCommandName));
|
|
46
|
+
for (const [canonical, aliases] of Object.entries(input.aliases ?? {})) {
|
|
47
|
+
if (!implemented.has(normalizeChannelCommandName(canonical)))
|
|
48
|
+
continue;
|
|
49
|
+
for (const alias of aliases) {
|
|
50
|
+
implemented.add(normalizeChannelCommandName(alias));
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const aliasNames = new Set(Object.values(input.aliases ?? {}).flat().map(normalizeChannelCommandName));
|
|
54
|
+
return {
|
|
55
|
+
advertised: [...advertised].sort(),
|
|
56
|
+
implemented: [...implemented].sort(),
|
|
57
|
+
missing: [...advertised].filter((name) => !implemented.has(name)).sort(),
|
|
58
|
+
extra: [...implemented].filter((name) => !advertised.has(name) && !aliasNames.has(name)).sort(),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
@@ -176,7 +176,11 @@ export class ChannelCommandService {
|
|
|
176
176
|
});
|
|
177
177
|
}
|
|
178
178
|
const mode = this.effectiveMirrorMode(options.source, options.contextKey, options.preferencesStore);
|
|
179
|
-
const minInterval = options.source === "telegram"
|
|
179
|
+
const minInterval = options.source === "telegram"
|
|
180
|
+
? this.config.telegramMirrorMinUpdateMs
|
|
181
|
+
: options.source === "discord"
|
|
182
|
+
? this.config.discordMirrorMinUpdateMs
|
|
183
|
+
: this.config.slackMirrorMinUpdateMs;
|
|
180
184
|
return {
|
|
181
185
|
plain: [
|
|
182
186
|
`CLI mirroring: ${mode}`,
|
|
@@ -334,13 +338,25 @@ export class ChannelCommandService {
|
|
|
334
338
|
};
|
|
335
339
|
}
|
|
336
340
|
defaultMirrorMode(source) {
|
|
337
|
-
return source === "telegram"
|
|
341
|
+
return source === "telegram"
|
|
342
|
+
? this.config.telegramMirrorMode
|
|
343
|
+
: source === "discord"
|
|
344
|
+
? this.config.discordMirrorMode
|
|
345
|
+
: this.config.slackMirrorMode;
|
|
338
346
|
}
|
|
339
347
|
defaultNotifyMode(source) {
|
|
340
|
-
return source === "telegram"
|
|
348
|
+
return source === "telegram"
|
|
349
|
+
? this.config.telegramNotifyMode
|
|
350
|
+
: source === "discord"
|
|
351
|
+
? this.config.discordNotifyMode
|
|
352
|
+
: this.config.slackNotifyMode;
|
|
341
353
|
}
|
|
342
354
|
defaultQuietHours(source) {
|
|
343
|
-
return source === "telegram"
|
|
355
|
+
return source === "telegram"
|
|
356
|
+
? this.config.telegramQuietHours
|
|
357
|
+
: source === "discord"
|
|
358
|
+
? this.config.discordQuietHours
|
|
359
|
+
: this.config.slackQuietHours;
|
|
344
360
|
}
|
|
345
361
|
effectiveMirrorMode(source, contextKey, preferencesStore) {
|
|
346
362
|
return preferencesStore.get(contextKey).mirrorMode ?? this.defaultMirrorMode(source);
|
|
@@ -49,7 +49,11 @@ export class ChannelMirrorRegistry {
|
|
|
49
49
|
return mirrors.some((mirror) => mirror.queuePaused) || this.promptStore.isPaused(sourceContextKey);
|
|
50
50
|
}
|
|
51
51
|
effectiveMirrorMode(contextKey, source, preferences) {
|
|
52
|
-
const configured = source === "telegram"
|
|
52
|
+
const configured = source === "telegram"
|
|
53
|
+
? this.config.telegramMirrorMode
|
|
54
|
+
: source === "discord"
|
|
55
|
+
? this.config.discordMirrorMode
|
|
56
|
+
: this.config.slackMirrorMode;
|
|
53
57
|
return preferences.get(contextKey).mirrorMode ?? configured;
|
|
54
58
|
}
|
|
55
59
|
snapshot() {
|
|
@@ -67,11 +71,14 @@ export function activeSessionSourceForContextKey(contextKey) {
|
|
|
67
71
|
if (channelId === "discord") {
|
|
68
72
|
return "discord";
|
|
69
73
|
}
|
|
74
|
+
if (channelId === "slack") {
|
|
75
|
+
return "slack";
|
|
76
|
+
}
|
|
70
77
|
if (channelId === "web") {
|
|
71
78
|
return "web";
|
|
72
79
|
}
|
|
73
80
|
return "cli";
|
|
74
81
|
}
|
|
75
82
|
export function isMirrorChannelSource(source) {
|
|
76
|
-
return source === "telegram" || source === "discord";
|
|
83
|
+
return source === "telegram" || source === "discord" || source === "slack";
|
|
77
84
|
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
export function createChannelTurnLifecycle(promptDescription) {
|
|
3
|
+
const startedAt = Date.now();
|
|
4
|
+
const turnId = randomUUID().slice(0, 12);
|
|
5
|
+
const progress = {
|
|
6
|
+
status: "running",
|
|
7
|
+
promptDescription,
|
|
8
|
+
startedAt,
|
|
9
|
+
updatedAt: startedAt,
|
|
10
|
+
toolCounts: new Map(),
|
|
11
|
+
textCharacters: 0,
|
|
12
|
+
};
|
|
13
|
+
const touch = () => {
|
|
14
|
+
progress.updatedAt = Date.now();
|
|
15
|
+
};
|
|
16
|
+
return {
|
|
17
|
+
turnId,
|
|
18
|
+
startedAt,
|
|
19
|
+
progress,
|
|
20
|
+
touch,
|
|
21
|
+
recordTextDelta: (characters) => {
|
|
22
|
+
progress.textCharacters += Math.max(0, characters);
|
|
23
|
+
touch();
|
|
24
|
+
},
|
|
25
|
+
recordToolStart: (toolName) => {
|
|
26
|
+
progress.currentTool = toolName;
|
|
27
|
+
progress.lastTool = toolName;
|
|
28
|
+
progress.toolCounts.set(toolName, (progress.toolCounts.get(toolName) ?? 0) + 1);
|
|
29
|
+
touch();
|
|
30
|
+
},
|
|
31
|
+
recordToolUpdate: touch,
|
|
32
|
+
recordToolEnd: () => {
|
|
33
|
+
progress.currentTool = undefined;
|
|
34
|
+
touch();
|
|
35
|
+
},
|
|
36
|
+
recordCompleted: () => {
|
|
37
|
+
progress.status = "completed";
|
|
38
|
+
progress.completedAt = Date.now();
|
|
39
|
+
progress.updatedAt = progress.completedAt;
|
|
40
|
+
},
|
|
41
|
+
recordFailed: (error) => {
|
|
42
|
+
progress.status = "failed";
|
|
43
|
+
progress.error = error;
|
|
44
|
+
progress.completedAt = Date.now();
|
|
45
|
+
progress.updatedAt = progress.completedAt;
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
export function createChannelTypingLoop(options) {
|
|
50
|
+
let timer;
|
|
51
|
+
let running = false;
|
|
52
|
+
const sendTyping = () => {
|
|
53
|
+
void options.sendTyping().catch(() => { });
|
|
54
|
+
};
|
|
55
|
+
return {
|
|
56
|
+
start: () => {
|
|
57
|
+
if (running) {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
running = true;
|
|
61
|
+
timer = setInterval(sendTyping, options.intervalMs);
|
|
62
|
+
timer.unref?.();
|
|
63
|
+
sendTyping();
|
|
64
|
+
},
|
|
65
|
+
stop: () => {
|
|
66
|
+
running = false;
|
|
67
|
+
if (timer) {
|
|
68
|
+
clearInterval(timer);
|
|
69
|
+
timer = undefined;
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|