jeopi-cli 16.6.11 → 16.6.13
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/CHANGELOG.md +22 -0
- package/README.md +34 -9
- package/dist/cli.js +3662 -3649
- package/dist/types/cli/update-cli.d.ts +9 -0
- package/dist/types/main.d.ts +1 -1
- package/dist/types/messenger/activity.d.ts +7 -0
- package/dist/types/messenger/artifacts.d.ts +12 -0
- package/dist/types/messenger/gateway.d.ts +5 -1
- package/dist/types/messenger/live-session.d.ts +4 -0
- package/dist/types/messenger/types.d.ts +18 -1
- package/dist/types/session/agent-session.d.ts +3 -1
- package/package.json +12 -12
- package/src/cli/update-cli.ts +13 -3
- package/src/extensibility/plugins/legacy-pi-bundled-keys.ts +2 -0
- package/src/extensibility/plugins/legacy-pi-bundled-registry.ts +8 -0
- package/src/internal-urls/docs-index.generated.txt +1 -1
- package/src/main.ts +12 -2
- package/src/messenger/activity.ts +24 -0
- package/src/messenger/artifacts.ts +167 -0
- package/src/messenger/config.ts +14 -2
- package/src/messenger/discord.ts +330 -22
- package/src/messenger/gateway.ts +225 -8
- package/src/messenger/http.ts +1 -0
- package/src/messenger/live-session.ts +219 -24
- package/src/messenger/session.ts +14 -2
- package/src/messenger/types.ts +23 -1
- package/src/modes/controllers/input-controller.ts +26 -4
- package/src/prompts/system/messenger-artifacts.md +4 -0
- package/src/session/agent-session.ts +14 -10
- package/src/slash-commands/builtin-registry.ts +25 -2
package/src/messenger/gateway.ts
CHANGED
|
@@ -1,17 +1,177 @@
|
|
|
1
1
|
import { logger } from "jeopi-utils";
|
|
2
|
-
import
|
|
2
|
+
import { MessengerHttpError } from "./http";
|
|
3
|
+
import type {
|
|
4
|
+
MessengerActivityReporter,
|
|
5
|
+
MessengerAdapter,
|
|
6
|
+
MessengerConfig,
|
|
7
|
+
MessengerMessage,
|
|
8
|
+
MessengerReply,
|
|
9
|
+
} from "./types";
|
|
3
10
|
import { MessengerFatalError } from "./types";
|
|
4
11
|
|
|
5
12
|
const MAX_MESSAGE_LENGTH = 32_000;
|
|
6
13
|
const MAX_QUEUED_MESSAGES = 100;
|
|
7
14
|
const MAX_SEEN_MESSAGES = 10_000;
|
|
15
|
+
const STATUS_TIMEOUT_MS = 1000;
|
|
16
|
+
const MAX_STATUS_IN_FLIGHT = 8;
|
|
17
|
+
const MAX_ACTIVITY_LENGTH = 120;
|
|
18
|
+
// Status edits are cosmetic: keep them sparse so they never compete with reply delivery for rate limits.
|
|
19
|
+
const ACTIVITY_INTERVAL_MS = 2_000;
|
|
20
|
+
const ACTIVITY_TIMEOUT_MS = 3_000;
|
|
21
|
+
|
|
22
|
+
/** Collapse to one bounded line: drop invisible format characters, fold control and whitespace runs. */
|
|
23
|
+
function activityLine(text: string): string {
|
|
24
|
+
const line = text
|
|
25
|
+
.replace(/\p{Cf}/gu, "")
|
|
26
|
+
.replace(/[\p{Cc}\p{Z}\s]+/gu, " ")
|
|
27
|
+
.trim();
|
|
28
|
+
if (line.length <= MAX_ACTIVITY_LENGTH) return line;
|
|
29
|
+
let end = MAX_ACTIVITY_LENGTH - 1;
|
|
30
|
+
const last = line.charCodeAt(end - 1);
|
|
31
|
+
if (last >= 0xd800 && last <= 0xdbff) end--;
|
|
32
|
+
return `${line.slice(0, end).trimEnd()}…`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface ActivityReporter {
|
|
36
|
+
report: MessengerActivityReporter;
|
|
37
|
+
/** Stop updates and remove any shown line; idempotent and never rejects. */
|
|
38
|
+
close(): Promise<void>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const NO_ACTIVITY: ActivityReporter = { report() {}, close: () => Promise.resolve() };
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Bound a cosmetic adapter call: a stalled provider request must not hold up the request queue. A timeout
|
|
45
|
+
* rejects so callers can retry; cancellation through `signal` resolves because the request is ending.
|
|
46
|
+
*/
|
|
47
|
+
async function boundedActivity(run: (signal: AbortSignal) => Promise<void>, signal?: AbortSignal): Promise<void> {
|
|
48
|
+
const timeout = new AbortController();
|
|
49
|
+
const callSignal = signal ? AbortSignal.any([signal, timeout.signal]) : timeout.signal;
|
|
50
|
+
const stopped = Promise.withResolvers<void>();
|
|
51
|
+
const onStop = (): void => {
|
|
52
|
+
if (timeout.signal.aborted) stopped.reject(new Error("Messenger activity update timed out"));
|
|
53
|
+
else stopped.resolve();
|
|
54
|
+
};
|
|
55
|
+
callSignal.addEventListener("abort", onStop, { once: true });
|
|
56
|
+
const timer = setTimeout(() => timeout.abort(), ACTIVITY_TIMEOUT_MS);
|
|
57
|
+
try {
|
|
58
|
+
await Promise.race([Promise.resolve().then(() => run(callSignal)), stopped.promise]);
|
|
59
|
+
} finally {
|
|
60
|
+
clearTimeout(timer);
|
|
61
|
+
callSignal.removeEventListener("abort", onStop);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Coalesce a request's activity into one status line: show the first report at once, then at most the
|
|
67
|
+
* latest line per interval. Removal skips the request signal so an aborted request still retires its line.
|
|
68
|
+
*/
|
|
69
|
+
function createActivityReporter(
|
|
70
|
+
adapter: MessengerAdapter,
|
|
71
|
+
destination: MessengerMessage,
|
|
72
|
+
signal: AbortSignal,
|
|
73
|
+
intervalMs: number,
|
|
74
|
+
): ActivityReporter {
|
|
75
|
+
const activity = adapter.activity;
|
|
76
|
+
if (!activity) return NO_ACTIVITY;
|
|
77
|
+
let latest: string | undefined;
|
|
78
|
+
let sent: string | undefined;
|
|
79
|
+
let shown = false;
|
|
80
|
+
let closed = false;
|
|
81
|
+
let pumping = false;
|
|
82
|
+
let pump = Promise.resolve();
|
|
83
|
+
let nextAt = 0;
|
|
84
|
+
let wake: (() => void) | undefined;
|
|
85
|
+
let closing: Promise<void> | undefined;
|
|
86
|
+
const update = (text: string | undefined, callSignal?: AbortSignal): Promise<void> =>
|
|
87
|
+
boundedActivity(bounded => activity.call(adapter, destination, text, bounded), callSignal);
|
|
88
|
+
const pause = async (ms: number): Promise<void> => {
|
|
89
|
+
const { promise, resolve } = Promise.withResolvers<void>();
|
|
90
|
+
const timer = setTimeout(resolve, ms);
|
|
91
|
+
wake = () => {
|
|
92
|
+
clearTimeout(timer);
|
|
93
|
+
resolve();
|
|
94
|
+
};
|
|
95
|
+
try {
|
|
96
|
+
await promise;
|
|
97
|
+
} finally {
|
|
98
|
+
wake = undefined;
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
const run = async (): Promise<void> => {
|
|
102
|
+
try {
|
|
103
|
+
while (!closed && !signal.aborted && latest !== sent) {
|
|
104
|
+
const delay = nextAt - Date.now();
|
|
105
|
+
if (delay > 0) {
|
|
106
|
+
await pause(delay);
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
const text = latest;
|
|
110
|
+
sent = text;
|
|
111
|
+
shown = true;
|
|
112
|
+
let spacing = intervalMs;
|
|
113
|
+
try {
|
|
114
|
+
await update(text, signal);
|
|
115
|
+
} catch (error) {
|
|
116
|
+
if (error instanceof MessengerHttpError && error.status === 429) {
|
|
117
|
+
// Keep the line pending: a rate-limited update is retried unless a newer line replaces it.
|
|
118
|
+
sent = undefined;
|
|
119
|
+
spacing = Math.max(spacing, error.retryAfterMs ?? 1_000);
|
|
120
|
+
} else if (!signal.aborted) {
|
|
121
|
+
logger.warn("Messenger activity update failed", { platform: destination.platform });
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
nextAt = Date.now() + spacing;
|
|
125
|
+
}
|
|
126
|
+
} finally {
|
|
127
|
+
pumping = false;
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
return {
|
|
131
|
+
report(text) {
|
|
132
|
+
if (closed || signal.aborted) return;
|
|
133
|
+
const line = activityLine(text);
|
|
134
|
+
if (!line) return;
|
|
135
|
+
latest = line;
|
|
136
|
+
if (pumping) return;
|
|
137
|
+
pumping = true;
|
|
138
|
+
pump = run();
|
|
139
|
+
},
|
|
140
|
+
close() {
|
|
141
|
+
closing ??= (async () => {
|
|
142
|
+
closed = true;
|
|
143
|
+
wake?.();
|
|
144
|
+
await pump;
|
|
145
|
+
if (!shown) return;
|
|
146
|
+
for (let attempt = 0; ; attempt++) {
|
|
147
|
+
try {
|
|
148
|
+
await update(undefined);
|
|
149
|
+
return;
|
|
150
|
+
} catch (error) {
|
|
151
|
+
const wait =
|
|
152
|
+
error instanceof MessengerHttpError && error.status === 429
|
|
153
|
+
? (error.retryAfterMs ?? 1_000)
|
|
154
|
+
: 1_000;
|
|
155
|
+
// One bounded retry: removal holds the request queue.
|
|
156
|
+
if (attempt > 0 || wait > ACTIVITY_TIMEOUT_MS) {
|
|
157
|
+
logger.warn("Messenger activity removal failed", { platform: destination.platform });
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
await Bun.sleep(wait);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
})();
|
|
164
|
+
return closing;
|
|
165
|
+
},
|
|
166
|
+
};
|
|
167
|
+
}
|
|
8
168
|
|
|
9
169
|
/** Include the sender even in shared threads: remote histories must not cross users. */
|
|
10
170
|
export function messengerConversationKey(message: MessengerMessage): string {
|
|
11
171
|
return JSON.stringify([
|
|
12
172
|
message.platform,
|
|
13
173
|
message.accountId,
|
|
14
|
-
message.channelId,
|
|
174
|
+
message.parentChannelId ?? message.channelId,
|
|
15
175
|
message.threadId ?? "",
|
|
16
176
|
message.senderId,
|
|
17
177
|
]);
|
|
@@ -25,11 +185,14 @@ export function isMessengerMessageAllowed(message: MessengerMessage, config: Mes
|
|
|
25
185
|
message.channelId &&
|
|
26
186
|
message.senderId &&
|
|
27
187
|
message.messageId &&
|
|
28
|
-
message.text.trim()
|
|
188
|
+
(message.text.trim() ||
|
|
189
|
+
(message.platform === "discord" && config.platforms.discord?.artifactDir && message.attachments?.length)) &&
|
|
29
190
|
message.text.length <= MAX_MESSAGE_LENGTH &&
|
|
30
191
|
platform.allowedUserIds.includes(message.senderId) &&
|
|
31
|
-
(!platform.allowedChannelIds ||
|
|
32
|
-
|
|
192
|
+
(!platform.allowedChannelIds ||
|
|
193
|
+
platform.allowedChannelIds.includes(message.channelId) ||
|
|
194
|
+
(Boolean(message.parentChannelId) && platform.allowedChannelIds.includes(message.parentChannelId!))) &&
|
|
195
|
+
(message.direct || message.mentioned || (message.platform === "discord" && Boolean(message.parentChannelId))),
|
|
33
196
|
);
|
|
34
197
|
}
|
|
35
198
|
|
|
@@ -45,11 +208,18 @@ export interface MessengerGateway {
|
|
|
45
208
|
handle(message: MessengerMessage, signal?: AbortSignal): Promise<void>;
|
|
46
209
|
}
|
|
47
210
|
|
|
211
|
+
export interface MessengerGatewayOptions {
|
|
212
|
+
/** Minimum spacing between in-place activity edits for one request. */
|
|
213
|
+
activityIntervalMs?: number;
|
|
214
|
+
}
|
|
215
|
+
|
|
48
216
|
export function createMessengerGateway(
|
|
49
217
|
config: MessengerConfig,
|
|
50
218
|
adapters: MessengerAdapter[],
|
|
51
219
|
reply: MessengerReply,
|
|
220
|
+
options: MessengerGatewayOptions = {},
|
|
52
221
|
): MessengerGateway {
|
|
222
|
+
const activityIntervalMs = Math.max(0, options.activityIntervalMs ?? ACTIVITY_INTERVAL_MS);
|
|
53
223
|
const byPlatform = new Map(adapters.map(adapter => [adapter.platform, adapter]));
|
|
54
224
|
if (byPlatform.size !== adapters.length) throw new Error("Duplicate messenger adapter");
|
|
55
225
|
for (const platform of Object.keys(config.platforms)) {
|
|
@@ -63,34 +233,81 @@ export function createMessengerGateway(
|
|
|
63
233
|
let workerActive = false;
|
|
64
234
|
let running = false;
|
|
65
235
|
let fatalError: MessengerFatalError | undefined;
|
|
236
|
+
let statusInFlight = 0;
|
|
66
237
|
|
|
67
238
|
async function drain(): Promise<void> {
|
|
68
239
|
while (queue.length > 0) {
|
|
69
240
|
const pending = queue.shift();
|
|
70
241
|
if (!pending) break;
|
|
71
242
|
const { message, adapter, signal, resolve } = pending;
|
|
243
|
+
let failed = false;
|
|
244
|
+
let activity = NO_ACTIVITY;
|
|
245
|
+
let statusChain = Promise.resolve();
|
|
246
|
+
const status = (state: "processing" | "success" | "failure"): void => {
|
|
247
|
+
if (!adapter.status) return;
|
|
248
|
+
statusChain = statusChain.then(async () => {
|
|
249
|
+
if (signal.aborted || statusInFlight >= MAX_STATUS_IN_FLIGHT) return;
|
|
250
|
+
const timeout = new AbortController();
|
|
251
|
+
const statusSignal = AbortSignal.any([signal, timeout.signal]);
|
|
252
|
+
const stopped = Promise.withResolvers<void>();
|
|
253
|
+
const onAbort = (): void => stopped.resolve();
|
|
254
|
+
statusSignal.addEventListener("abort", onAbort, { once: true });
|
|
255
|
+
statusInFlight++;
|
|
256
|
+
const operation = Promise.resolve().then(() => adapter.status!(message, state, statusSignal));
|
|
257
|
+
void operation.then(
|
|
258
|
+
() => {
|
|
259
|
+
statusInFlight--;
|
|
260
|
+
},
|
|
261
|
+
() => {
|
|
262
|
+
statusInFlight--;
|
|
263
|
+
},
|
|
264
|
+
);
|
|
265
|
+
const timer = setTimeout(() => timeout.abort(), STATUS_TIMEOUT_MS);
|
|
266
|
+
try {
|
|
267
|
+
await Promise.race([operation, stopped.promise]);
|
|
268
|
+
} catch {
|
|
269
|
+
if (!signal.aborted) logger.warn("Messenger status update failed", { platform: message.platform });
|
|
270
|
+
} finally {
|
|
271
|
+
clearTimeout(timer);
|
|
272
|
+
statusSignal.removeEventListener("abort", onAbort);
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
};
|
|
72
276
|
try {
|
|
73
277
|
if (signal.aborted) continue;
|
|
278
|
+
status("processing");
|
|
279
|
+
const destination = (await adapter.prepare?.(message, signal)) ?? message;
|
|
280
|
+
activity = createActivityReporter(adapter, destination, signal, activityIntervalMs);
|
|
74
281
|
let text: string;
|
|
75
282
|
try {
|
|
76
|
-
text = await reply(
|
|
283
|
+
text = await reply(destination, signal, activity.report);
|
|
77
284
|
} catch (error) {
|
|
285
|
+
failed = true;
|
|
78
286
|
if (error instanceof MessengerFatalError) {
|
|
79
287
|
fatalError = error;
|
|
288
|
+
status("failure");
|
|
80
289
|
lifetime.abort();
|
|
81
290
|
continue;
|
|
82
291
|
}
|
|
83
292
|
if (signal.aborted) continue;
|
|
84
293
|
logger.warn("Messenger agent turn failed", { platform: message.platform });
|
|
85
294
|
text = "The request could not be completed. Please try again.";
|
|
295
|
+
} finally {
|
|
296
|
+
// Retire the status line alongside reply delivery rather than delaying the reply on it.
|
|
297
|
+
void activity.close();
|
|
86
298
|
}
|
|
87
299
|
if (!signal.aborted && text.trim()) {
|
|
88
300
|
// A failed send may already have delivered a chunk: never retry the entire reply.
|
|
89
|
-
await adapter.send(
|
|
301
|
+
await adapter.send(destination, text, signal);
|
|
90
302
|
}
|
|
303
|
+
if (!signal.aborted) status(failed ? "failure" : "success");
|
|
91
304
|
} catch {
|
|
92
|
-
if (!signal.aborted)
|
|
305
|
+
if (!signal.aborted) {
|
|
306
|
+
logger.warn("Messenger reply delivery failed", { platform: message.platform });
|
|
307
|
+
status("failure");
|
|
308
|
+
}
|
|
93
309
|
} finally {
|
|
310
|
+
await activity.close();
|
|
94
311
|
resolve();
|
|
95
312
|
}
|
|
96
313
|
}
|
package/src/messenger/http.ts
CHANGED
|
@@ -65,6 +65,7 @@ export async function messengerFetchJson(
|
|
|
65
65
|
if (!response.bodyUsed) await response.body?.cancel();
|
|
66
66
|
throw new MessengerHttpError(response.status, retryAfterMs);
|
|
67
67
|
}
|
|
68
|
+
if (response.status === 204) return undefined;
|
|
68
69
|
return await response.json();
|
|
69
70
|
} catch (error) {
|
|
70
71
|
if (error instanceof MessengerHttpError) throw error;
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { AssistantMessage, UserMessage } from "jeopi-ai";
|
|
2
2
|
import type { InteractiveModeContext } from "../modes/types";
|
|
3
|
+
import { createMessengerActivityTracker } from "./activity";
|
|
4
|
+
import { messengerArtifactPrompt } from "./artifacts";
|
|
3
5
|
import { loadMessengerConfig, validateMessengerEnvironment } from "./config";
|
|
4
6
|
import { createDiscordAdapter, type DiscordTransportDeps } from "./discord";
|
|
5
7
|
import { createMessengerGateway, isMessengerMessageAllowed, type MessengerGateway } from "./gateway";
|
|
@@ -21,12 +23,18 @@ export interface LiveDiscordBridgeDependencies {
|
|
|
21
23
|
export interface LiveDiscordBridge {
|
|
22
24
|
readonly gateway: MessengerGateway;
|
|
23
25
|
readonly active: boolean;
|
|
26
|
+
readonly relayDestination: string | undefined;
|
|
27
|
+
setRelayDestination(channelId: string | undefined): void;
|
|
28
|
+
withLocalTurn<T>(
|
|
29
|
+
text: string,
|
|
30
|
+
submit: (onPreparedUserMessage: (message: UserMessage) => void) => Promise<T>,
|
|
31
|
+
): Promise<T>;
|
|
24
32
|
run(signal?: AbortSignal): Promise<void>;
|
|
25
33
|
close(): Promise<void>;
|
|
26
34
|
}
|
|
27
35
|
|
|
28
36
|
interface TurnCapture {
|
|
29
|
-
|
|
37
|
+
promptText: string;
|
|
30
38
|
promptSeen: boolean;
|
|
31
39
|
dispatched: boolean;
|
|
32
40
|
hostSettled: boolean;
|
|
@@ -143,8 +151,13 @@ export function createLiveDiscordBridge(
|
|
|
143
151
|
let currentTurn: TurnCapture | undefined;
|
|
144
152
|
let pollTimer: NodeJS.Timeout;
|
|
145
153
|
let bridgeRef: LiveDiscordBridge | undefined;
|
|
154
|
+
let relayDestination: string | undefined;
|
|
155
|
+
let relayLifetime = new AbortController();
|
|
156
|
+
let relaySend = Promise.resolve();
|
|
157
|
+
let localCapture: object | undefined;
|
|
146
158
|
let close: () => Promise<void>;
|
|
147
|
-
|
|
159
|
+
let revoke: () => Promise<void>;
|
|
160
|
+
const reply: MessengerReply = async (message, signal, onActivity) => {
|
|
148
161
|
if (closed || signal?.aborted) throw new Error(LIVE_UNAVAILABLE_MESSAGE);
|
|
149
162
|
if (!sameSession(ctx, attachedSession, sessionId, sessionFile)) throw new Error(LIVE_UNAVAILABLE_MESSAGE);
|
|
150
163
|
if (!isMessengerMessageAllowed(message, scopedConfig)) throw new Error(LIVE_UNAVAILABLE_MESSAGE);
|
|
@@ -156,10 +169,11 @@ export function createLiveDiscordBridge(
|
|
|
156
169
|
attachedSession.isBashRunning ||
|
|
157
170
|
attachedSession.isEvalRunning ||
|
|
158
171
|
attachedSession.hasPostPromptWork ||
|
|
172
|
+
localCapture ||
|
|
159
173
|
currentTurn
|
|
160
174
|
)
|
|
161
175
|
return LIVE_BUSY_MESSAGE;
|
|
162
|
-
|
|
176
|
+
let promptText = `[Discord:${message.messageId} sender:${message.senderId} channel:${message.channelId}] ${message.text}`;
|
|
163
177
|
const capture: TurnCapture = {
|
|
164
178
|
promptText,
|
|
165
179
|
promptSeen: false,
|
|
@@ -173,8 +187,17 @@ export function createLiveDiscordBridge(
|
|
|
173
187
|
ctx.showStatus(`Discord request from ${message.senderId}`);
|
|
174
188
|
} catch {}
|
|
175
189
|
const operationSignal = signal ? AbortSignal.any([lifetime.signal, signal]) : lifetime.signal;
|
|
190
|
+
const onAbort = (): void => {
|
|
191
|
+
void revoke();
|
|
192
|
+
};
|
|
193
|
+
operationSignal.addEventListener("abort", onAbort, { once: true });
|
|
194
|
+
const track = onActivity && createMessengerActivityTracker(onActivity);
|
|
176
195
|
const unsubscribe = attachedSession.subscribe(event => {
|
|
177
|
-
if (event.type !== "message_end")
|
|
196
|
+
if (event.type !== "message_end") {
|
|
197
|
+
// Report only this Discord turn: its prompt event precedes the turn's tool and retry events.
|
|
198
|
+
if (capture.promptSeen && !capture.conflicted) track?.(event);
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
178
201
|
if (event.message.role === "user") {
|
|
179
202
|
const text = userMessageText(event.message);
|
|
180
203
|
if (!capture.promptSeen && text === capture.promptText) {
|
|
@@ -187,6 +210,21 @@ export function createLiveDiscordBridge(
|
|
|
187
210
|
if (capture.promptSeen && isCompetingMessage(event.message)) capture.conflicted = true;
|
|
188
211
|
});
|
|
189
212
|
try {
|
|
213
|
+
const guidance = await messengerArtifactPrompt(
|
|
214
|
+
scopedConfig.platforms.discord?.artifactDir,
|
|
215
|
+
message,
|
|
216
|
+
operationSignal,
|
|
217
|
+
);
|
|
218
|
+
if (guidance) promptText += `\n\n${guidance}`;
|
|
219
|
+
capture.promptText = promptText;
|
|
220
|
+
if (
|
|
221
|
+
attachedSession.isStreaming ||
|
|
222
|
+
attachedSession.isCompacting ||
|
|
223
|
+
attachedSession.isBashRunning ||
|
|
224
|
+
attachedSession.isEvalRunning ||
|
|
225
|
+
localCapture
|
|
226
|
+
)
|
|
227
|
+
throw new Error(LIVE_UNAVAILABLE_MESSAGE);
|
|
190
228
|
if (closed || operationSignal.aborted || !sameSession(ctx, attachedSession, sessionId, sessionFile))
|
|
191
229
|
throw new Error(LIVE_UNAVAILABLE_MESSAGE);
|
|
192
230
|
const promptPromise = attachedSession.prompt(promptText, {
|
|
@@ -222,6 +260,7 @@ export function createLiveDiscordBridge(
|
|
|
222
260
|
if (!response.trim()) throw new Error("Live Discord response unavailable");
|
|
223
261
|
return response;
|
|
224
262
|
} finally {
|
|
263
|
+
operationSignal.removeEventListener("abort", onAbort);
|
|
225
264
|
unsubscribe();
|
|
226
265
|
if (currentTurn === capture) currentTurn = undefined;
|
|
227
266
|
}
|
|
@@ -232,6 +271,7 @@ export function createLiveDiscordBridge(
|
|
|
232
271
|
const adapterFactory = dependencies.createAdapter ?? createDiscordAdapter;
|
|
233
272
|
const gatewayFactory = dependencies.createGateway ?? createMessengerGateway;
|
|
234
273
|
const adapter = adapterFactory(discordConfig, dependencies.env, dependencies.transport);
|
|
274
|
+
const activity = adapter.activity;
|
|
235
275
|
const guardedAdapter: MessengerAdapter = {
|
|
236
276
|
...adapter,
|
|
237
277
|
send: async (message, text, signal) => {
|
|
@@ -244,37 +284,187 @@ export function createLiveDiscordBridge(
|
|
|
244
284
|
}
|
|
245
285
|
await adapter.send(message, text, sendSignal);
|
|
246
286
|
},
|
|
287
|
+
// New status text requires the attached session; removal must still retire a shown line after close.
|
|
288
|
+
activity:
|
|
289
|
+
activity &&
|
|
290
|
+
(async (message, text, signal) => {
|
|
291
|
+
if (text !== undefined && (closed || !sameSession(ctx, attachedSession, sessionId, sessionFile))) return;
|
|
292
|
+
await activity.call(adapter, message, text, signal);
|
|
293
|
+
}),
|
|
247
294
|
};
|
|
248
|
-
const
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
295
|
+
const setRelayDestination = (channelId: string | undefined): void => {
|
|
296
|
+
if (closed || !sameSession(ctx, attachedSession, sessionId, sessionFile))
|
|
297
|
+
throw new Error(LIVE_UNAVAILABLE_MESSAGE);
|
|
298
|
+
if (channelId !== undefined && !discordConfig.allowedChannelIds?.includes(channelId)) {
|
|
299
|
+
throw new Error("Discord relay destination must be an explicitly allowed channel ID");
|
|
300
|
+
}
|
|
301
|
+
relayLifetime.abort();
|
|
302
|
+
relayLifetime = new AbortController();
|
|
303
|
+
relayDestination = channelId;
|
|
304
|
+
};
|
|
305
|
+
const withLocalTurn = async <T>(
|
|
306
|
+
_text: string,
|
|
307
|
+
submit: (onPreparedUserMessage: (message: UserMessage) => void) => Promise<T>,
|
|
308
|
+
): Promise<T> => {
|
|
309
|
+
const destination = relayDestination;
|
|
310
|
+
if (!destination || closed || !sameSession(ctx, attachedSession, sessionId, sessionFile)) return submit(() => {});
|
|
311
|
+
if (
|
|
312
|
+
currentTurn ||
|
|
313
|
+
localCapture ||
|
|
314
|
+
attachedSession.isStreaming ||
|
|
315
|
+
attachedSession.isCompacting ||
|
|
316
|
+
attachedSession.isRetrying ||
|
|
317
|
+
attachedSession.isGeneratingHandoff ||
|
|
318
|
+
attachedSession.isBashRunning ||
|
|
319
|
+
attachedSession.isEvalRunning ||
|
|
320
|
+
attachedSession.hasPostPromptWork
|
|
321
|
+
) {
|
|
322
|
+
try {
|
|
323
|
+
ctx.showWarning("Discord relay skipped: another session operation is in progress.");
|
|
324
|
+
} catch {}
|
|
325
|
+
return submit(() => {});
|
|
253
326
|
}
|
|
327
|
+
const signal = AbortSignal.any([lifetime.signal, relayLifetime.signal]);
|
|
328
|
+
const capture = {};
|
|
329
|
+
localCapture = capture;
|
|
330
|
+
let promptSeen = false;
|
|
331
|
+
let preparedMessage: UserMessage | undefined;
|
|
332
|
+
let conflicted = false;
|
|
333
|
+
let response: AssistantMessage | undefined;
|
|
334
|
+
const unsubscribe = attachedSession.subscribe(event => {
|
|
335
|
+
if (event.type !== "message_end") return;
|
|
336
|
+
if (event.message.role === "user") {
|
|
337
|
+
if (!promptSeen && preparedMessage === event.message) promptSeen = true;
|
|
338
|
+
else conflicted = true;
|
|
339
|
+
} else if (isCompetingMessage(event.message)) {
|
|
340
|
+
conflicted = true;
|
|
341
|
+
} else if (promptSeen && event.message.role === "assistant") {
|
|
342
|
+
response = event.message;
|
|
343
|
+
}
|
|
344
|
+
});
|
|
345
|
+
let cleaned = false;
|
|
346
|
+
let submitting = true;
|
|
347
|
+
const cleanup = (): void => {
|
|
348
|
+
if (cleaned) return;
|
|
349
|
+
cleaned = true;
|
|
350
|
+
signal.removeEventListener("abort", cleanup);
|
|
351
|
+
unsubscribe();
|
|
352
|
+
if (!submitting && localCapture === capture) localCapture = undefined;
|
|
353
|
+
};
|
|
354
|
+
signal.addEventListener("abort", cleanup, { once: true });
|
|
355
|
+
if (signal.aborted) cleanup();
|
|
356
|
+
try {
|
|
357
|
+
const result = await submit(message => {
|
|
358
|
+
preparedMessage = message;
|
|
359
|
+
});
|
|
360
|
+
submitting = false;
|
|
361
|
+
if (cleaned && localCapture === capture) localCapture = undefined;
|
|
362
|
+
void (async () => {
|
|
363
|
+
try {
|
|
364
|
+
await awaitWithAbort(attachedSession.waitForIdle(), signal);
|
|
365
|
+
if (result === false || signal.aborted || !sameSession(ctx, attachedSession, sessionId, sessionFile))
|
|
366
|
+
return;
|
|
367
|
+
if (!promptSeen || conflicted || currentTurn) {
|
|
368
|
+
try {
|
|
369
|
+
ctx.showWarning(
|
|
370
|
+
"Discord relay skipped: could not safely identify this local turn or another turn intervened.",
|
|
371
|
+
);
|
|
372
|
+
} catch {}
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
if (response?.stopReason !== "stop") {
|
|
376
|
+
try {
|
|
377
|
+
ctx.showWarning("Discord relay skipped: the local turn did not finish successfully.");
|
|
378
|
+
} catch {}
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
const output = assistantMessageText(response);
|
|
382
|
+
cleanup();
|
|
383
|
+
if (!output.trim()) {
|
|
384
|
+
try {
|
|
385
|
+
ctx.showWarning("Discord relay skipped: the local response contains no text.");
|
|
386
|
+
} catch {}
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
const message = {
|
|
390
|
+
platform: "discord" as const,
|
|
391
|
+
accountId: "",
|
|
392
|
+
channelId: destination,
|
|
393
|
+
senderId: "",
|
|
394
|
+
messageId: "",
|
|
395
|
+
text: "",
|
|
396
|
+
direct: false,
|
|
397
|
+
mentioned: false,
|
|
398
|
+
};
|
|
399
|
+
const send = relaySend.then(async () => {
|
|
400
|
+
if (signal.aborted || !sameSession(ctx, attachedSession, sessionId, sessionFile)) return;
|
|
401
|
+
await guardedAdapter.send(message, output, signal);
|
|
402
|
+
});
|
|
403
|
+
relaySend = send.catch(() => {});
|
|
404
|
+
await send;
|
|
405
|
+
} catch (error) {
|
|
406
|
+
if (!signal.aborted) {
|
|
407
|
+
try {
|
|
408
|
+
ctx.showWarning(
|
|
409
|
+
`Discord relay delivery failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
410
|
+
);
|
|
411
|
+
} catch {}
|
|
412
|
+
}
|
|
413
|
+
} finally {
|
|
414
|
+
cleanup();
|
|
415
|
+
}
|
|
416
|
+
})();
|
|
417
|
+
return result;
|
|
418
|
+
} catch (error) {
|
|
419
|
+
submitting = false;
|
|
420
|
+
cleanup();
|
|
421
|
+
if (localCapture === capture) localCapture = undefined;
|
|
422
|
+
throw error;
|
|
423
|
+
}
|
|
424
|
+
};
|
|
425
|
+
const gateway = gatewayFactory(scopedConfig, [guardedAdapter], reply);
|
|
426
|
+
let revokePromise: Promise<void> | undefined;
|
|
427
|
+
revoke = (): Promise<void> => {
|
|
428
|
+
if (revokePromise) return revokePromise;
|
|
429
|
+
const completion = Promise.withResolvers<void>();
|
|
430
|
+
revokePromise = completion.promise;
|
|
431
|
+
const turn = currentTurn;
|
|
254
432
|
closed = true;
|
|
255
433
|
clearInterval(pollTimer);
|
|
434
|
+
relayLifetime.abort();
|
|
435
|
+
relayDestination = undefined;
|
|
256
436
|
lifetime.abort();
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
try {
|
|
261
|
-
await awaitHostAbort(attachedSession.abort({ reason: "Discord live bridge revoked" }));
|
|
262
|
-
} catch {
|
|
437
|
+
void (async () => {
|
|
438
|
+
if (turn && sameSession(ctx, attachedSession, sessionId, sessionFile)) {
|
|
439
|
+
if (turn.dispatched && !turn.hostSettled && !turn.conflicted) {
|
|
263
440
|
try {
|
|
264
|
-
|
|
441
|
+
await awaitHostAbort(attachedSession.abort({ reason: "Discord live bridge revoked" }));
|
|
442
|
+
} catch {
|
|
443
|
+
try {
|
|
444
|
+
ctx.showWarning("Discord live turn could not be interrupted; press Esc to interrupt it.");
|
|
445
|
+
} catch {}
|
|
446
|
+
}
|
|
447
|
+
} else if (!turn.hostSettled) {
|
|
448
|
+
try {
|
|
449
|
+
ctx.showWarning("A Discord-initiated turn is still running; press Esc to interrupt.");
|
|
265
450
|
} catch {}
|
|
266
451
|
}
|
|
267
|
-
} else if (!turn.hostSettled) {
|
|
268
|
-
try {
|
|
269
|
-
ctx.showWarning("A Discord-initiated turn is still running; press Esc to interrupt.");
|
|
270
|
-
} catch {}
|
|
271
452
|
}
|
|
272
|
-
}
|
|
453
|
+
})().then(completion.resolve, completion.reject);
|
|
454
|
+
return revokePromise;
|
|
455
|
+
};
|
|
456
|
+
close = async (): Promise<void> => {
|
|
457
|
+
await revoke();
|
|
273
458
|
await runPromise?.catch(() => {});
|
|
274
459
|
};
|
|
275
460
|
|
|
276
461
|
const run = (signal?: AbortSignal): Promise<void> => {
|
|
277
462
|
if (runPromise) return runPromise;
|
|
463
|
+
const onAbort = (): void => {
|
|
464
|
+
void revoke();
|
|
465
|
+
};
|
|
466
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
467
|
+
if (signal?.aborted) onAbort();
|
|
278
468
|
const effectiveSignal = signal ? AbortSignal.any([lifetime.signal, signal]) : lifetime.signal;
|
|
279
469
|
runPromise = gateway
|
|
280
470
|
.run(effectiveSignal)
|
|
@@ -285,9 +475,9 @@ export function createLiveDiscordBridge(
|
|
|
285
475
|
ctx.showStatus("Discord live bridge detached: session changed");
|
|
286
476
|
}
|
|
287
477
|
})
|
|
288
|
-
.finally(() => {
|
|
289
|
-
|
|
290
|
-
|
|
478
|
+
.finally(async () => {
|
|
479
|
+
signal?.removeEventListener("abort", onAbort);
|
|
480
|
+
await revoke();
|
|
291
481
|
});
|
|
292
482
|
return runPromise;
|
|
293
483
|
};
|
|
@@ -303,6 +493,11 @@ export function createLiveDiscordBridge(
|
|
|
303
493
|
get active() {
|
|
304
494
|
return !closed;
|
|
305
495
|
},
|
|
496
|
+
get relayDestination() {
|
|
497
|
+
return relayDestination;
|
|
498
|
+
},
|
|
499
|
+
setRelayDestination,
|
|
500
|
+
withLocalTurn,
|
|
306
501
|
run,
|
|
307
502
|
close,
|
|
308
503
|
};
|
package/src/messenger/session.ts
CHANGED
|
@@ -10,6 +10,8 @@ import type { AgentSession } from "../session/agent-session";
|
|
|
10
10
|
import type { AuthStorage } from "../session/auth-storage";
|
|
11
11
|
import { USER_INTERRUPT_LABEL } from "../session/messages";
|
|
12
12
|
import { SessionManager } from "../session/session-manager";
|
|
13
|
+
import { createMessengerActivityTracker } from "./activity";
|
|
14
|
+
import { messengerArtifactPrompt } from "./artifacts";
|
|
13
15
|
import { messengerConversationKey } from "./gateway";
|
|
14
16
|
import { type MessengerConfig, MessengerFatalError, type MessengerReply } from "./types";
|
|
15
17
|
|
|
@@ -122,12 +124,13 @@ export function createMessengerSessionBridge(
|
|
|
122
124
|
activeRoute = route;
|
|
123
125
|
}
|
|
124
126
|
|
|
125
|
-
const reply: MessengerReply = async (message, signal) => {
|
|
127
|
+
const reply: MessengerReply = async (message, signal, onActivity) => {
|
|
126
128
|
if (closed || signal.aborted) throw new Error("Messenger session is closed");
|
|
127
129
|
if (activeTurn) throw new Error("Messenger turns must be serialized");
|
|
128
130
|
const finished = Promise.withResolvers<void>();
|
|
129
131
|
activeTurn = finished.promise;
|
|
130
132
|
let aborting: Promise<void> | undefined;
|
|
133
|
+
let unsubscribe: (() => void) | undefined;
|
|
131
134
|
const onAbort = (): void => {
|
|
132
135
|
if (session && !aborting) {
|
|
133
136
|
aborting = session.abort({ reason: USER_INTERRUPT_LABEL });
|
|
@@ -175,7 +178,15 @@ export function createMessengerSessionBridge(
|
|
|
175
178
|
await constrainTools(current);
|
|
176
179
|
if (closed || signal.aborted || runtimeFailed) throw new Error("Messenger session unavailable");
|
|
177
180
|
// Slash-looking text is ordinary user data, never an extension command or developer message.
|
|
178
|
-
|
|
181
|
+
let prompt = message.text;
|
|
182
|
+
const artifactRoot = message.platform === "discord" ? config.platforms.discord?.artifactDir : undefined;
|
|
183
|
+
if (message.platform === "discord") {
|
|
184
|
+
const guidance = await messengerArtifactPrompt(artifactRoot, message, signal);
|
|
185
|
+
if (guidance) prompt += `\n\n${guidance}`;
|
|
186
|
+
}
|
|
187
|
+
// Turns are serialized, so every event until idle belongs to this request.
|
|
188
|
+
if (onActivity) unsubscribe = current.subscribe(createMessengerActivityTracker(onActivity));
|
|
189
|
+
const dispatched = await current.prompt(prompt, { expandPromptTemplates: false });
|
|
179
190
|
await current.waitForIdle();
|
|
180
191
|
await current.sessionManager.flush();
|
|
181
192
|
if (!dispatched || closed || signal.aborted || runtimeFailed) throw new Error("Messenger request failed");
|
|
@@ -190,6 +201,7 @@ export function createMessengerSessionBridge(
|
|
|
190
201
|
if (!text.trim()) throw new Error("Messenger response is empty");
|
|
191
202
|
return text;
|
|
192
203
|
} finally {
|
|
204
|
+
unsubscribe?.();
|
|
193
205
|
signal.removeEventListener("abort", onAbort);
|
|
194
206
|
await aborting?.catch(() => {});
|
|
195
207
|
activeTurn = undefined;
|