jeopi-cli 16.6.11 → 16.6.12
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 +12 -0
- package/README.md +34 -9
- package/dist/cli.js +3805 -3797
- package/dist/types/main.d.ts +1 -1
- package/dist/types/messenger/artifacts.d.ts +12 -0
- package/dist/types/messenger/live-session.d.ts +4 -0
- package/dist/types/messenger/types.d.ts +10 -0
- package/dist/types/session/agent-session.d.ts +3 -1
- package/package.json +12 -12
- package/src/internal-urls/docs-index.generated.txt +1 -1
- package/src/main.ts +12 -2
- package/src/messenger/artifacts.ts +167 -0
- package/src/messenger/config.ts +14 -2
- package/src/messenger/discord.ts +272 -22
- package/src/messenger/gateway.ts +53 -7
- package/src/messenger/http.ts +1 -0
- package/src/messenger/live-session.ts +203 -22
- package/src/messenger/session.ts +8 -1
- package/src/messenger/types.ts +10 -0
- 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
|
@@ -5,13 +5,15 @@ import { MessengerFatalError } from "./types";
|
|
|
5
5
|
const MAX_MESSAGE_LENGTH = 32_000;
|
|
6
6
|
const MAX_QUEUED_MESSAGES = 100;
|
|
7
7
|
const MAX_SEEN_MESSAGES = 10_000;
|
|
8
|
+
const STATUS_TIMEOUT_MS = 1000;
|
|
9
|
+
const MAX_STATUS_IN_FLIGHT = 8;
|
|
8
10
|
|
|
9
11
|
/** Include the sender even in shared threads: remote histories must not cross users. */
|
|
10
12
|
export function messengerConversationKey(message: MessengerMessage): string {
|
|
11
13
|
return JSON.stringify([
|
|
12
14
|
message.platform,
|
|
13
15
|
message.accountId,
|
|
14
|
-
message.channelId,
|
|
16
|
+
message.parentChannelId ?? message.channelId,
|
|
15
17
|
message.threadId ?? "",
|
|
16
18
|
message.senderId,
|
|
17
19
|
]);
|
|
@@ -25,11 +27,14 @@ export function isMessengerMessageAllowed(message: MessengerMessage, config: Mes
|
|
|
25
27
|
message.channelId &&
|
|
26
28
|
message.senderId &&
|
|
27
29
|
message.messageId &&
|
|
28
|
-
message.text.trim()
|
|
30
|
+
(message.text.trim() ||
|
|
31
|
+
(message.platform === "discord" && config.platforms.discord?.artifactDir && message.attachments?.length)) &&
|
|
29
32
|
message.text.length <= MAX_MESSAGE_LENGTH &&
|
|
30
33
|
platform.allowedUserIds.includes(message.senderId) &&
|
|
31
|
-
(!platform.allowedChannelIds ||
|
|
32
|
-
|
|
34
|
+
(!platform.allowedChannelIds ||
|
|
35
|
+
platform.allowedChannelIds.includes(message.channelId) ||
|
|
36
|
+
(Boolean(message.parentChannelId) && platform.allowedChannelIds.includes(message.parentChannelId!))) &&
|
|
37
|
+
(message.direct || message.mentioned || (message.platform === "discord" && Boolean(message.parentChannelId))),
|
|
33
38
|
);
|
|
34
39
|
}
|
|
35
40
|
|
|
@@ -63,20 +68,57 @@ export function createMessengerGateway(
|
|
|
63
68
|
let workerActive = false;
|
|
64
69
|
let running = false;
|
|
65
70
|
let fatalError: MessengerFatalError | undefined;
|
|
71
|
+
let statusInFlight = 0;
|
|
66
72
|
|
|
67
73
|
async function drain(): Promise<void> {
|
|
68
74
|
while (queue.length > 0) {
|
|
69
75
|
const pending = queue.shift();
|
|
70
76
|
if (!pending) break;
|
|
71
77
|
const { message, adapter, signal, resolve } = pending;
|
|
78
|
+
let failed = false;
|
|
79
|
+
let statusChain = Promise.resolve();
|
|
80
|
+
const status = (state: "processing" | "success" | "failure"): void => {
|
|
81
|
+
if (!adapter.status) return;
|
|
82
|
+
statusChain = statusChain.then(async () => {
|
|
83
|
+
if (signal.aborted || statusInFlight >= MAX_STATUS_IN_FLIGHT) return;
|
|
84
|
+
const timeout = new AbortController();
|
|
85
|
+
const statusSignal = AbortSignal.any([signal, timeout.signal]);
|
|
86
|
+
const stopped = Promise.withResolvers<void>();
|
|
87
|
+
const onAbort = (): void => stopped.resolve();
|
|
88
|
+
statusSignal.addEventListener("abort", onAbort, { once: true });
|
|
89
|
+
statusInFlight++;
|
|
90
|
+
const operation = Promise.resolve().then(() => adapter.status!(message, state, statusSignal));
|
|
91
|
+
void operation.then(
|
|
92
|
+
() => {
|
|
93
|
+
statusInFlight--;
|
|
94
|
+
},
|
|
95
|
+
() => {
|
|
96
|
+
statusInFlight--;
|
|
97
|
+
},
|
|
98
|
+
);
|
|
99
|
+
const timer = setTimeout(() => timeout.abort(), STATUS_TIMEOUT_MS);
|
|
100
|
+
try {
|
|
101
|
+
await Promise.race([operation, stopped.promise]);
|
|
102
|
+
} catch {
|
|
103
|
+
if (!signal.aborted) logger.warn("Messenger status update failed", { platform: message.platform });
|
|
104
|
+
} finally {
|
|
105
|
+
clearTimeout(timer);
|
|
106
|
+
statusSignal.removeEventListener("abort", onAbort);
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
};
|
|
72
110
|
try {
|
|
73
111
|
if (signal.aborted) continue;
|
|
112
|
+
status("processing");
|
|
113
|
+
const destination = (await adapter.prepare?.(message, signal)) ?? message;
|
|
74
114
|
let text: string;
|
|
75
115
|
try {
|
|
76
|
-
text = await reply(
|
|
116
|
+
text = await reply(destination, signal);
|
|
77
117
|
} catch (error) {
|
|
118
|
+
failed = true;
|
|
78
119
|
if (error instanceof MessengerFatalError) {
|
|
79
120
|
fatalError = error;
|
|
121
|
+
status("failure");
|
|
80
122
|
lifetime.abort();
|
|
81
123
|
continue;
|
|
82
124
|
}
|
|
@@ -86,10 +128,14 @@ export function createMessengerGateway(
|
|
|
86
128
|
}
|
|
87
129
|
if (!signal.aborted && text.trim()) {
|
|
88
130
|
// A failed send may already have delivered a chunk: never retry the entire reply.
|
|
89
|
-
await adapter.send(
|
|
131
|
+
await adapter.send(destination, text, signal);
|
|
90
132
|
}
|
|
133
|
+
if (!signal.aborted) status(failed ? "failure" : "success");
|
|
91
134
|
} catch {
|
|
92
|
-
if (!signal.aborted)
|
|
135
|
+
if (!signal.aborted) {
|
|
136
|
+
logger.warn("Messenger reply delivery failed", { platform: message.platform });
|
|
137
|
+
status("failure");
|
|
138
|
+
}
|
|
93
139
|
} finally {
|
|
94
140
|
resolve();
|
|
95
141
|
}
|
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,6 @@
|
|
|
1
1
|
import type { AssistantMessage, UserMessage } from "jeopi-ai";
|
|
2
2
|
import type { InteractiveModeContext } from "../modes/types";
|
|
3
|
+
import { messengerArtifactPrompt } from "./artifacts";
|
|
3
4
|
import { loadMessengerConfig, validateMessengerEnvironment } from "./config";
|
|
4
5
|
import { createDiscordAdapter, type DiscordTransportDeps } from "./discord";
|
|
5
6
|
import { createMessengerGateway, isMessengerMessageAllowed, type MessengerGateway } from "./gateway";
|
|
@@ -21,12 +22,18 @@ export interface LiveDiscordBridgeDependencies {
|
|
|
21
22
|
export interface LiveDiscordBridge {
|
|
22
23
|
readonly gateway: MessengerGateway;
|
|
23
24
|
readonly active: boolean;
|
|
25
|
+
readonly relayDestination: string | undefined;
|
|
26
|
+
setRelayDestination(channelId: string | undefined): void;
|
|
27
|
+
withLocalTurn<T>(
|
|
28
|
+
text: string,
|
|
29
|
+
submit: (onPreparedUserMessage: (message: UserMessage) => void) => Promise<T>,
|
|
30
|
+
): Promise<T>;
|
|
24
31
|
run(signal?: AbortSignal): Promise<void>;
|
|
25
32
|
close(): Promise<void>;
|
|
26
33
|
}
|
|
27
34
|
|
|
28
35
|
interface TurnCapture {
|
|
29
|
-
|
|
36
|
+
promptText: string;
|
|
30
37
|
promptSeen: boolean;
|
|
31
38
|
dispatched: boolean;
|
|
32
39
|
hostSettled: boolean;
|
|
@@ -143,7 +150,12 @@ export function createLiveDiscordBridge(
|
|
|
143
150
|
let currentTurn: TurnCapture | undefined;
|
|
144
151
|
let pollTimer: NodeJS.Timeout;
|
|
145
152
|
let bridgeRef: LiveDiscordBridge | undefined;
|
|
153
|
+
let relayDestination: string | undefined;
|
|
154
|
+
let relayLifetime = new AbortController();
|
|
155
|
+
let relaySend = Promise.resolve();
|
|
156
|
+
let localCapture: object | undefined;
|
|
146
157
|
let close: () => Promise<void>;
|
|
158
|
+
let revoke: () => Promise<void>;
|
|
147
159
|
const reply: MessengerReply = async (message, signal) => {
|
|
148
160
|
if (closed || signal?.aborted) throw new Error(LIVE_UNAVAILABLE_MESSAGE);
|
|
149
161
|
if (!sameSession(ctx, attachedSession, sessionId, sessionFile)) throw new Error(LIVE_UNAVAILABLE_MESSAGE);
|
|
@@ -156,10 +168,11 @@ export function createLiveDiscordBridge(
|
|
|
156
168
|
attachedSession.isBashRunning ||
|
|
157
169
|
attachedSession.isEvalRunning ||
|
|
158
170
|
attachedSession.hasPostPromptWork ||
|
|
171
|
+
localCapture ||
|
|
159
172
|
currentTurn
|
|
160
173
|
)
|
|
161
174
|
return LIVE_BUSY_MESSAGE;
|
|
162
|
-
|
|
175
|
+
let promptText = `[Discord:${message.messageId} sender:${message.senderId} channel:${message.channelId}] ${message.text}`;
|
|
163
176
|
const capture: TurnCapture = {
|
|
164
177
|
promptText,
|
|
165
178
|
promptSeen: false,
|
|
@@ -173,6 +186,10 @@ export function createLiveDiscordBridge(
|
|
|
173
186
|
ctx.showStatus(`Discord request from ${message.senderId}`);
|
|
174
187
|
} catch {}
|
|
175
188
|
const operationSignal = signal ? AbortSignal.any([lifetime.signal, signal]) : lifetime.signal;
|
|
189
|
+
const onAbort = (): void => {
|
|
190
|
+
void revoke();
|
|
191
|
+
};
|
|
192
|
+
operationSignal.addEventListener("abort", onAbort, { once: true });
|
|
176
193
|
const unsubscribe = attachedSession.subscribe(event => {
|
|
177
194
|
if (event.type !== "message_end") return;
|
|
178
195
|
if (event.message.role === "user") {
|
|
@@ -187,6 +204,21 @@ export function createLiveDiscordBridge(
|
|
|
187
204
|
if (capture.promptSeen && isCompetingMessage(event.message)) capture.conflicted = true;
|
|
188
205
|
});
|
|
189
206
|
try {
|
|
207
|
+
const guidance = await messengerArtifactPrompt(
|
|
208
|
+
scopedConfig.platforms.discord?.artifactDir,
|
|
209
|
+
message,
|
|
210
|
+
operationSignal,
|
|
211
|
+
);
|
|
212
|
+
if (guidance) promptText += `\n\n${guidance}`;
|
|
213
|
+
capture.promptText = promptText;
|
|
214
|
+
if (
|
|
215
|
+
attachedSession.isStreaming ||
|
|
216
|
+
attachedSession.isCompacting ||
|
|
217
|
+
attachedSession.isBashRunning ||
|
|
218
|
+
attachedSession.isEvalRunning ||
|
|
219
|
+
localCapture
|
|
220
|
+
)
|
|
221
|
+
throw new Error(LIVE_UNAVAILABLE_MESSAGE);
|
|
190
222
|
if (closed || operationSignal.aborted || !sameSession(ctx, attachedSession, sessionId, sessionFile))
|
|
191
223
|
throw new Error(LIVE_UNAVAILABLE_MESSAGE);
|
|
192
224
|
const promptPromise = attachedSession.prompt(promptText, {
|
|
@@ -222,6 +254,7 @@ export function createLiveDiscordBridge(
|
|
|
222
254
|
if (!response.trim()) throw new Error("Live Discord response unavailable");
|
|
223
255
|
return response;
|
|
224
256
|
} finally {
|
|
257
|
+
operationSignal.removeEventListener("abort", onAbort);
|
|
225
258
|
unsubscribe();
|
|
226
259
|
if (currentTurn === capture) currentTurn = undefined;
|
|
227
260
|
}
|
|
@@ -245,36 +278,179 @@ export function createLiveDiscordBridge(
|
|
|
245
278
|
await adapter.send(message, text, sendSignal);
|
|
246
279
|
},
|
|
247
280
|
};
|
|
248
|
-
const
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
281
|
+
const setRelayDestination = (channelId: string | undefined): void => {
|
|
282
|
+
if (closed || !sameSession(ctx, attachedSession, sessionId, sessionFile))
|
|
283
|
+
throw new Error(LIVE_UNAVAILABLE_MESSAGE);
|
|
284
|
+
if (channelId !== undefined && !discordConfig.allowedChannelIds?.includes(channelId)) {
|
|
285
|
+
throw new Error("Discord relay destination must be an explicitly allowed channel ID");
|
|
286
|
+
}
|
|
287
|
+
relayLifetime.abort();
|
|
288
|
+
relayLifetime = new AbortController();
|
|
289
|
+
relayDestination = channelId;
|
|
290
|
+
};
|
|
291
|
+
const withLocalTurn = async <T>(
|
|
292
|
+
_text: string,
|
|
293
|
+
submit: (onPreparedUserMessage: (message: UserMessage) => void) => Promise<T>,
|
|
294
|
+
): Promise<T> => {
|
|
295
|
+
const destination = relayDestination;
|
|
296
|
+
if (!destination || closed || !sameSession(ctx, attachedSession, sessionId, sessionFile)) return submit(() => {});
|
|
297
|
+
if (
|
|
298
|
+
currentTurn ||
|
|
299
|
+
localCapture ||
|
|
300
|
+
attachedSession.isStreaming ||
|
|
301
|
+
attachedSession.isCompacting ||
|
|
302
|
+
attachedSession.isRetrying ||
|
|
303
|
+
attachedSession.isGeneratingHandoff ||
|
|
304
|
+
attachedSession.isBashRunning ||
|
|
305
|
+
attachedSession.isEvalRunning ||
|
|
306
|
+
attachedSession.hasPostPromptWork
|
|
307
|
+
) {
|
|
308
|
+
try {
|
|
309
|
+
ctx.showWarning("Discord relay skipped: another session operation is in progress.");
|
|
310
|
+
} catch {}
|
|
311
|
+
return submit(() => {});
|
|
312
|
+
}
|
|
313
|
+
const signal = AbortSignal.any([lifetime.signal, relayLifetime.signal]);
|
|
314
|
+
const capture = {};
|
|
315
|
+
localCapture = capture;
|
|
316
|
+
let promptSeen = false;
|
|
317
|
+
let preparedMessage: UserMessage | undefined;
|
|
318
|
+
let conflicted = false;
|
|
319
|
+
let response: AssistantMessage | undefined;
|
|
320
|
+
const unsubscribe = attachedSession.subscribe(event => {
|
|
321
|
+
if (event.type !== "message_end") return;
|
|
322
|
+
if (event.message.role === "user") {
|
|
323
|
+
if (!promptSeen && preparedMessage === event.message) promptSeen = true;
|
|
324
|
+
else conflicted = true;
|
|
325
|
+
} else if (isCompetingMessage(event.message)) {
|
|
326
|
+
conflicted = true;
|
|
327
|
+
} else if (promptSeen && event.message.role === "assistant") {
|
|
328
|
+
response = event.message;
|
|
329
|
+
}
|
|
330
|
+
});
|
|
331
|
+
let cleaned = false;
|
|
332
|
+
let submitting = true;
|
|
333
|
+
const cleanup = (): void => {
|
|
334
|
+
if (cleaned) return;
|
|
335
|
+
cleaned = true;
|
|
336
|
+
signal.removeEventListener("abort", cleanup);
|
|
337
|
+
unsubscribe();
|
|
338
|
+
if (!submitting && localCapture === capture) localCapture = undefined;
|
|
339
|
+
};
|
|
340
|
+
signal.addEventListener("abort", cleanup, { once: true });
|
|
341
|
+
if (signal.aborted) cleanup();
|
|
342
|
+
try {
|
|
343
|
+
const result = await submit(message => {
|
|
344
|
+
preparedMessage = message;
|
|
345
|
+
});
|
|
346
|
+
submitting = false;
|
|
347
|
+
if (cleaned && localCapture === capture) localCapture = undefined;
|
|
348
|
+
void (async () => {
|
|
349
|
+
try {
|
|
350
|
+
await awaitWithAbort(attachedSession.waitForIdle(), signal);
|
|
351
|
+
if (result === false || signal.aborted || !sameSession(ctx, attachedSession, sessionId, sessionFile))
|
|
352
|
+
return;
|
|
353
|
+
if (!promptSeen || conflicted || currentTurn) {
|
|
354
|
+
try {
|
|
355
|
+
ctx.showWarning(
|
|
356
|
+
"Discord relay skipped: could not safely identify this local turn or another turn intervened.",
|
|
357
|
+
);
|
|
358
|
+
} catch {}
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
if (response?.stopReason !== "stop") {
|
|
362
|
+
try {
|
|
363
|
+
ctx.showWarning("Discord relay skipped: the local turn did not finish successfully.");
|
|
364
|
+
} catch {}
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
const output = assistantMessageText(response);
|
|
368
|
+
cleanup();
|
|
369
|
+
if (!output.trim()) {
|
|
370
|
+
try {
|
|
371
|
+
ctx.showWarning("Discord relay skipped: the local response contains no text.");
|
|
372
|
+
} catch {}
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
const message = {
|
|
376
|
+
platform: "discord" as const,
|
|
377
|
+
accountId: "",
|
|
378
|
+
channelId: destination,
|
|
379
|
+
senderId: "",
|
|
380
|
+
messageId: "",
|
|
381
|
+
text: "",
|
|
382
|
+
direct: false,
|
|
383
|
+
mentioned: false,
|
|
384
|
+
};
|
|
385
|
+
const send = relaySend.then(async () => {
|
|
386
|
+
if (signal.aborted || !sameSession(ctx, attachedSession, sessionId, sessionFile)) return;
|
|
387
|
+
await guardedAdapter.send(message, output, signal);
|
|
388
|
+
});
|
|
389
|
+
relaySend = send.catch(() => {});
|
|
390
|
+
await send;
|
|
391
|
+
} catch (error) {
|
|
392
|
+
if (!signal.aborted) {
|
|
393
|
+
try {
|
|
394
|
+
ctx.showWarning(
|
|
395
|
+
`Discord relay delivery failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
396
|
+
);
|
|
397
|
+
} catch {}
|
|
398
|
+
}
|
|
399
|
+
} finally {
|
|
400
|
+
cleanup();
|
|
401
|
+
}
|
|
402
|
+
})();
|
|
403
|
+
return result;
|
|
404
|
+
} catch (error) {
|
|
405
|
+
submitting = false;
|
|
406
|
+
cleanup();
|
|
407
|
+
if (localCapture === capture) localCapture = undefined;
|
|
408
|
+
throw error;
|
|
253
409
|
}
|
|
410
|
+
};
|
|
411
|
+
const gateway = gatewayFactory(scopedConfig, [guardedAdapter], reply);
|
|
412
|
+
let revokePromise: Promise<void> | undefined;
|
|
413
|
+
revoke = (): Promise<void> => {
|
|
414
|
+
if (revokePromise) return revokePromise;
|
|
415
|
+
const completion = Promise.withResolvers<void>();
|
|
416
|
+
revokePromise = completion.promise;
|
|
417
|
+
const turn = currentTurn;
|
|
254
418
|
closed = true;
|
|
255
419
|
clearInterval(pollTimer);
|
|
420
|
+
relayLifetime.abort();
|
|
421
|
+
relayDestination = undefined;
|
|
256
422
|
lifetime.abort();
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
try {
|
|
261
|
-
await awaitHostAbort(attachedSession.abort({ reason: "Discord live bridge revoked" }));
|
|
262
|
-
} catch {
|
|
423
|
+
void (async () => {
|
|
424
|
+
if (turn && sameSession(ctx, attachedSession, sessionId, sessionFile)) {
|
|
425
|
+
if (turn.dispatched && !turn.hostSettled && !turn.conflicted) {
|
|
263
426
|
try {
|
|
264
|
-
|
|
427
|
+
await awaitHostAbort(attachedSession.abort({ reason: "Discord live bridge revoked" }));
|
|
428
|
+
} catch {
|
|
429
|
+
try {
|
|
430
|
+
ctx.showWarning("Discord live turn could not be interrupted; press Esc to interrupt it.");
|
|
431
|
+
} catch {}
|
|
432
|
+
}
|
|
433
|
+
} else if (!turn.hostSettled) {
|
|
434
|
+
try {
|
|
435
|
+
ctx.showWarning("A Discord-initiated turn is still running; press Esc to interrupt.");
|
|
265
436
|
} catch {}
|
|
266
437
|
}
|
|
267
|
-
} else if (!turn.hostSettled) {
|
|
268
|
-
try {
|
|
269
|
-
ctx.showWarning("A Discord-initiated turn is still running; press Esc to interrupt.");
|
|
270
|
-
} catch {}
|
|
271
438
|
}
|
|
272
|
-
}
|
|
439
|
+
})().then(completion.resolve, completion.reject);
|
|
440
|
+
return revokePromise;
|
|
441
|
+
};
|
|
442
|
+
close = async (): Promise<void> => {
|
|
443
|
+
await revoke();
|
|
273
444
|
await runPromise?.catch(() => {});
|
|
274
445
|
};
|
|
275
446
|
|
|
276
447
|
const run = (signal?: AbortSignal): Promise<void> => {
|
|
277
448
|
if (runPromise) return runPromise;
|
|
449
|
+
const onAbort = (): void => {
|
|
450
|
+
void revoke();
|
|
451
|
+
};
|
|
452
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
453
|
+
if (signal?.aborted) onAbort();
|
|
278
454
|
const effectiveSignal = signal ? AbortSignal.any([lifetime.signal, signal]) : lifetime.signal;
|
|
279
455
|
runPromise = gateway
|
|
280
456
|
.run(effectiveSignal)
|
|
@@ -285,9 +461,9 @@ export function createLiveDiscordBridge(
|
|
|
285
461
|
ctx.showStatus("Discord live bridge detached: session changed");
|
|
286
462
|
}
|
|
287
463
|
})
|
|
288
|
-
.finally(() => {
|
|
289
|
-
|
|
290
|
-
|
|
464
|
+
.finally(async () => {
|
|
465
|
+
signal?.removeEventListener("abort", onAbort);
|
|
466
|
+
await revoke();
|
|
291
467
|
});
|
|
292
468
|
return runPromise;
|
|
293
469
|
};
|
|
@@ -303,6 +479,11 @@ export function createLiveDiscordBridge(
|
|
|
303
479
|
get active() {
|
|
304
480
|
return !closed;
|
|
305
481
|
},
|
|
482
|
+
get relayDestination() {
|
|
483
|
+
return relayDestination;
|
|
484
|
+
},
|
|
485
|
+
setRelayDestination,
|
|
486
|
+
withLocalTurn,
|
|
306
487
|
run,
|
|
307
488
|
close,
|
|
308
489
|
};
|
package/src/messenger/session.ts
CHANGED
|
@@ -10,6 +10,7 @@ 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 { messengerArtifactPrompt } from "./artifacts";
|
|
13
14
|
import { messengerConversationKey } from "./gateway";
|
|
14
15
|
import { type MessengerConfig, MessengerFatalError, type MessengerReply } from "./types";
|
|
15
16
|
|
|
@@ -175,7 +176,13 @@ export function createMessengerSessionBridge(
|
|
|
175
176
|
await constrainTools(current);
|
|
176
177
|
if (closed || signal.aborted || runtimeFailed) throw new Error("Messenger session unavailable");
|
|
177
178
|
// Slash-looking text is ordinary user data, never an extension command or developer message.
|
|
178
|
-
|
|
179
|
+
let prompt = message.text;
|
|
180
|
+
const artifactRoot = message.platform === "discord" ? config.platforms.discord?.artifactDir : undefined;
|
|
181
|
+
if (message.platform === "discord") {
|
|
182
|
+
const guidance = await messengerArtifactPrompt(artifactRoot, message, signal);
|
|
183
|
+
if (guidance) prompt += `\n\n${guidance}`;
|
|
184
|
+
}
|
|
185
|
+
const dispatched = await current.prompt(prompt, { expandPromptTemplates: false });
|
|
179
186
|
await current.waitForIdle();
|
|
180
187
|
await current.sessionManager.flush();
|
|
181
188
|
if (!dispatched || closed || signal.aborted || runtimeFailed) throw new Error("Messenger request failed");
|
package/src/messenger/types.ts
CHANGED
|
@@ -8,14 +8,22 @@ export interface MessengerMessage {
|
|
|
8
8
|
messageId: string;
|
|
9
9
|
text: string;
|
|
10
10
|
threadId?: string;
|
|
11
|
+
parentChannelId?: string;
|
|
11
12
|
direct: boolean;
|
|
12
13
|
mentioned: boolean;
|
|
14
|
+
attachments?: { url: string; filename: string; size: number }[];
|
|
13
15
|
}
|
|
14
16
|
|
|
15
17
|
export interface MessengerAdapter {
|
|
16
18
|
platform: MessengerPlatform;
|
|
17
19
|
start(onMessage: (message: MessengerMessage) => Promise<void>, signal: AbortSignal): Promise<void>;
|
|
18
20
|
send(message: MessengerMessage, text: string, signal?: AbortSignal): Promise<void>;
|
|
21
|
+
prepare?(message: MessengerMessage, signal?: AbortSignal): Promise<MessengerMessage>;
|
|
22
|
+
status?(
|
|
23
|
+
message: MessengerMessage,
|
|
24
|
+
status: "processing" | "success" | "failure",
|
|
25
|
+
signal?: AbortSignal,
|
|
26
|
+
): Promise<void>;
|
|
19
27
|
}
|
|
20
28
|
|
|
21
29
|
export interface MessengerPlatformConfig {
|
|
@@ -23,6 +31,8 @@ export interface MessengerPlatformConfig {
|
|
|
23
31
|
appTokenEnv?: string;
|
|
24
32
|
allowedUserIds: string[];
|
|
25
33
|
allowedChannelIds?: string[];
|
|
34
|
+
/** Dedicated operator-approved directory for Discord output files. */
|
|
35
|
+
artifactDir?: string;
|
|
26
36
|
}
|
|
27
37
|
|
|
28
38
|
export interface MessengerConfig {
|
|
@@ -903,7 +903,19 @@ export class InputController {
|
|
|
903
903
|
try {
|
|
904
904
|
await this.ctx.withLocalSubmission(
|
|
905
905
|
text,
|
|
906
|
-
() =>
|
|
906
|
+
() => {
|
|
907
|
+
const submit = () => this.ctx.session.prompt(text, { streamingBehavior: "steer", images });
|
|
908
|
+
const bridge = this.ctx.liveDiscordBridge;
|
|
909
|
+
return bridge?.active
|
|
910
|
+
? bridge.withLocalTurn(text, onPreparedUserMessage =>
|
|
911
|
+
this.ctx.session.prompt(text, {
|
|
912
|
+
streamingBehavior: "steer",
|
|
913
|
+
images,
|
|
914
|
+
onPreparedUserMessage,
|
|
915
|
+
}),
|
|
916
|
+
)
|
|
917
|
+
: submit();
|
|
918
|
+
},
|
|
907
919
|
{
|
|
908
920
|
imageCount: images?.length ?? 0,
|
|
909
921
|
},
|
|
@@ -1229,9 +1241,19 @@ export class InputController {
|
|
|
1229
1241
|
// Not streaming — just submit normally
|
|
1230
1242
|
this.ctx.editor.clearDraft(text);
|
|
1231
1243
|
try {
|
|
1232
|
-
await this.ctx.withLocalSubmission(
|
|
1233
|
-
|
|
1234
|
-
|
|
1244
|
+
await this.ctx.withLocalSubmission(
|
|
1245
|
+
text,
|
|
1246
|
+
() => {
|
|
1247
|
+
const submit = () => this.ctx.session.prompt(text, { images });
|
|
1248
|
+
const bridge = this.ctx.liveDiscordBridge;
|
|
1249
|
+
return bridge?.active
|
|
1250
|
+
? bridge.withLocalTurn(text, onPreparedUserMessage =>
|
|
1251
|
+
this.ctx.session.prompt(text, { images, onPreparedUserMessage }),
|
|
1252
|
+
)
|
|
1253
|
+
: submit();
|
|
1254
|
+
},
|
|
1255
|
+
{ imageCount: images?.length ?? 0 },
|
|
1256
|
+
);
|
|
1235
1257
|
} catch (error) {
|
|
1236
1258
|
restoreOnError(error);
|
|
1237
1259
|
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
To deliver a requested file, write it inside {{directory}} and put [artifact:relative/path] on its own line in your final response. Only files in that directory can be uploaded; each must be at most 8 MiB. Never include credentials or secrets in output files.
|
|
2
|
+
{{#if inputs}}
|
|
3
|
+
Incoming files: {{inputs}}
|
|
4
|
+
{{/if}}
|
|
@@ -97,6 +97,7 @@ import type {
|
|
|
97
97
|
ToolChoice,
|
|
98
98
|
Usage,
|
|
99
99
|
UsageReport,
|
|
100
|
+
UserMessage,
|
|
100
101
|
} from "jeopi-ai";
|
|
101
102
|
import {
|
|
102
103
|
calculateRateLimitBackoffMs,
|
|
@@ -944,6 +945,8 @@ export interface PromptOptions {
|
|
|
944
945
|
userInitiated?: boolean;
|
|
945
946
|
/** Explicit billing/initiator attribution for the prompt. Defaults to user prompts as `user` and synthetic prompts as `agent`. */
|
|
946
947
|
attribution?: MessageAttribution;
|
|
948
|
+
/** Observe the exact user message prepared for this submission, including queued messages. */
|
|
949
|
+
onPreparedUserMessage?: (message: UserMessage) => void;
|
|
947
950
|
/** Skip pre-send compaction checks for this prompt (internal use for maintenance flows). */
|
|
948
951
|
skipCompactionCheck?: boolean;
|
|
949
952
|
}
|
|
@@ -7539,9 +7542,9 @@ export class AgentSession {
|
|
|
7539
7542
|
await this.sendCustomMessage(notice, { deliverAs: options.streamingBehavior });
|
|
7540
7543
|
}
|
|
7541
7544
|
if (options.streamingBehavior === "followUp") {
|
|
7542
|
-
await this.#queueUserMessage(expandedText, options?.images, "followUp");
|
|
7545
|
+
await this.#queueUserMessage(expandedText, options?.images, "followUp", options?.onPreparedUserMessage);
|
|
7543
7546
|
} else {
|
|
7544
|
-
await this.#queueUserMessage(expandedText, options?.images, "steer");
|
|
7547
|
+
await this.#queueUserMessage(expandedText, options?.images, "steer", options?.onPreparedUserMessage);
|
|
7545
7548
|
}
|
|
7546
7549
|
return true;
|
|
7547
7550
|
}
|
|
@@ -7568,6 +7571,7 @@ export class AgentSession {
|
|
|
7568
7571
|
const message = options?.synthetic
|
|
7569
7572
|
? { role: "developer" as const, content: userContent, attribution: promptAttribution, timestamp: Date.now() }
|
|
7570
7573
|
: { role: "user" as const, content: userContent, attribution: promptAttribution, timestamp: Date.now() };
|
|
7574
|
+
if (message.role === "user") options?.onPreparedUserMessage?.(message);
|
|
7571
7575
|
|
|
7572
7576
|
const preludeMessages: AgentMessage[] = [];
|
|
7573
7577
|
if (eagerTodoPrelude) {
|
|
@@ -8021,6 +8025,7 @@ export class AgentSession {
|
|
|
8021
8025
|
text: string,
|
|
8022
8026
|
images: ImageContent[] | undefined,
|
|
8023
8027
|
mode: "steer" | "followUp",
|
|
8028
|
+
onPreparedUserMessage?: (message: UserMessage) => void,
|
|
8024
8029
|
): Promise<void> {
|
|
8025
8030
|
// A queued user message (RPC/SDK/collab steer or follow-up, or a typed message
|
|
8026
8031
|
// while streaming) is a deliberate resume; re-enable advisor auto-resume that
|
|
@@ -8038,21 +8043,20 @@ export class AgentSession {
|
|
|
8038
8043
|
: undefined;
|
|
8039
8044
|
if (mode === "followUp") {
|
|
8040
8045
|
if (imageDescriptionNotice) this.agent.followUp(imageDescriptionNotice);
|
|
8041
|
-
|
|
8042
|
-
|
|
8043
|
-
|
|
8044
|
-
attribution: "user",
|
|
8045
|
-
timestamp: Date.now(),
|
|
8046
|
-
});
|
|
8046
|
+
const message: UserMessage = { role: "user", content, attribution: "user", timestamp: Date.now() };
|
|
8047
|
+
onPreparedUserMessage?.(message);
|
|
8048
|
+
this.agent.followUp(message);
|
|
8047
8049
|
} else {
|
|
8048
8050
|
if (imageDescriptionNotice) this.agent.steer(imageDescriptionNotice);
|
|
8049
|
-
|
|
8051
|
+
const message: UserMessage = {
|
|
8050
8052
|
role: "user",
|
|
8051
8053
|
content,
|
|
8052
8054
|
steering: true,
|
|
8053
8055
|
attribution: "user",
|
|
8054
8056
|
timestamp: Date.now(),
|
|
8055
|
-
}
|
|
8057
|
+
};
|
|
8058
|
+
onPreparedUserMessage?.(message);
|
|
8059
|
+
this.agent.steer(message);
|
|
8056
8060
|
}
|
|
8057
8061
|
this.#scheduleIdleQueueDrain();
|
|
8058
8062
|
}
|
|
@@ -661,9 +661,10 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
|
|
|
661
661
|
{
|
|
662
662
|
name: "discord",
|
|
663
663
|
description: "Opt in to this session from an authorized Discord channel",
|
|
664
|
-
inlineHint: "[live|stop|status] [configPath] [--allow-full-control]",
|
|
664
|
+
inlineHint: "[live|relay|stop|status] [configPath|channelId|off] [--allow-full-control]",
|
|
665
665
|
subcommands: [
|
|
666
666
|
{ name: "live", description: "Start the live Discord bridge with a private config file" },
|
|
667
|
+
{ name: "relay", description: "Relay local responses to an allowed channel, or turn relay off" },
|
|
667
668
|
{ name: "status", description: "Show live Discord bridge status" },
|
|
668
669
|
{ name: "stop", description: "Stop the live Discord bridge" },
|
|
669
670
|
],
|
|
@@ -682,10 +683,32 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
|
|
|
682
683
|
const approvalMode = String(ctx.session.settings.get("tools.approvalMode"));
|
|
683
684
|
const activeTools = ctx.session.getActiveToolNames();
|
|
684
685
|
ctx.showStatus(
|
|
685
|
-
`Discord live bridge is active with full host control acknowledged. Approval mode: ${approvalMode}; active tools: ${activeTools.length > 0 ? activeTools.join(", ") : "none"}.`,
|
|
686
|
+
`Discord live bridge is active with full host control acknowledged. Approval mode: ${approvalMode}; active tools: ${activeTools.length > 0 ? activeTools.join(", ") : "none"}. Local response relay: ${ctx.liveDiscordBridge.relayDestination ?? "off"}.`,
|
|
686
687
|
);
|
|
687
688
|
return;
|
|
688
689
|
}
|
|
690
|
+
if (verb === "relay") {
|
|
691
|
+
const bridge = ctx.liveDiscordBridge;
|
|
692
|
+
if (!bridge?.active) {
|
|
693
|
+
ctx.showError("Start the Discord live bridge before enabling relay");
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
if (!rest || /\s/.test(rest)) {
|
|
697
|
+
ctx.showError("Usage: /discord relay <allowed-channel-id|off>");
|
|
698
|
+
return;
|
|
699
|
+
}
|
|
700
|
+
try {
|
|
701
|
+
bridge.setRelayDestination(rest === "off" ? undefined : rest);
|
|
702
|
+
ctx.showStatus(
|
|
703
|
+
rest === "off"
|
|
704
|
+
? "Discord local response relay is off"
|
|
705
|
+
: `Discord local responses will be sent to channel ${rest}; /discord relay off revokes relay.`,
|
|
706
|
+
);
|
|
707
|
+
} catch (error) {
|
|
708
|
+
ctx.showError(`Failed to configure Discord relay: ${errorMessage(error)}`);
|
|
709
|
+
}
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
689
712
|
if (verb === "stop") {
|
|
690
713
|
const bridge = ctx.liveDiscordBridge;
|
|
691
714
|
const start = liveDiscordStarts.get(ctx);
|