jeopi-cli 16.6.12 → 16.6.14
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 +20 -0
- package/README.md +2 -2
- package/dist/cli.js +2629 -2624
- package/dist/types/cli/messenger-cli.d.ts +4 -1
- package/dist/types/cli/update-cli.d.ts +9 -0
- package/dist/types/messenger/activity.d.ts +7 -0
- package/dist/types/messenger/gateway.d.ts +12 -1
- package/dist/types/messenger/session-pool.d.ts +38 -0
- package/dist/types/messenger/session-worker.d.ts +49 -0
- package/dist/types/messenger/types.d.ts +8 -1
- package/package.json +12 -12
- package/src/cli/messenger-cli.ts +22 -24
- package/src/cli/update-cli.ts +13 -3
- package/src/cli.ts +13 -0
- 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/messenger/activity.ts +24 -0
- package/src/messenger/discord.ts +58 -0
- package/src/messenger/gateway.ts +297 -73
- package/src/messenger/live-session.ts +16 -2
- package/src/messenger/session-pool.ts +332 -0
- package/src/messenger/session-worker.ts +79 -0
- package/src/messenger/session.ts +8 -5
- package/src/messenger/types.ts +13 -1
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { AgentSessionEvent } from "../session/agent-session";
|
|
2
|
+
import type { MessengerActivityReporter } from "./types";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Derive one-line progress from session events: the model's declared tool intent (falling back to the
|
|
6
|
+
* tool name) and long-running lifecycle notices. Raw tool arguments, results and errors never surface.
|
|
7
|
+
*/
|
|
8
|
+
export function createMessengerActivityTracker(report: MessengerActivityReporter): (event: AgentSessionEvent) => void {
|
|
9
|
+
let step = 0;
|
|
10
|
+
return event => {
|
|
11
|
+
switch (event.type) {
|
|
12
|
+
case "tool_execution_start":
|
|
13
|
+
step++;
|
|
14
|
+
report(`Step ${step} · ${event.intent ?? `Using ${event.toolName}`}`);
|
|
15
|
+
return;
|
|
16
|
+
case "auto_compaction_start":
|
|
17
|
+
report("Compacting context");
|
|
18
|
+
return;
|
|
19
|
+
case "auto_retry_start":
|
|
20
|
+
report(`Retrying after a provider error (${event.attempt}/${event.maxAttempts})`);
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
}
|
package/src/messenger/discord.ts
CHANGED
|
@@ -14,6 +14,8 @@ const PERMANENT_CLOSE_CODES = new Set([4004, 4010, 4011, 4012, 4013, 4014]);
|
|
|
14
14
|
// they are part of the delivered text, and consume one UTF-16 unit each.
|
|
15
15
|
const DISCORD_WHITESPACE = /^[\s\u0085\u001c-\u001f]$/u;
|
|
16
16
|
const DISCORD_BOUNDARY_GUARD = "\u200b";
|
|
17
|
+
// SUPPRESS_EMBEDS | SUPPRESS_NOTIFICATIONS: a status line never unfurls links or pings on creation.
|
|
18
|
+
const DISCORD_ACTIVITY_FLAGS = (1 << 2) | (1 << 12);
|
|
17
19
|
|
|
18
20
|
function splitDiscordText(text: string): { chunks: string[]; needsAttachment: boolean } {
|
|
19
21
|
const chunks: string[] = [];
|
|
@@ -333,6 +335,7 @@ export function createDiscordAdapter(
|
|
|
333
335
|
if (oldest !== undefined) createdThreads.delete(oldest);
|
|
334
336
|
}
|
|
335
337
|
};
|
|
338
|
+
const activityMessages = new Map<string, string>();
|
|
336
339
|
const headers = (): Record<string, string> => {
|
|
337
340
|
if (!token) throw new DiscordConfigurationError("Discord bot token is missing");
|
|
338
341
|
return { Authorization: `Bot ${token}`, "Content-Type": "application/json" };
|
|
@@ -504,6 +507,61 @@ export function createDiscordAdapter(
|
|
|
504
507
|
deps.fetch,
|
|
505
508
|
);
|
|
506
509
|
},
|
|
510
|
+
async activity(message, text, signal) {
|
|
511
|
+
const channelId = message.threadId ?? message.channelId;
|
|
512
|
+
const key = JSON.stringify([channelId, message.messageId]);
|
|
513
|
+
const messages = `${API}/channels/${encodeURIComponent(channelId)}/messages`;
|
|
514
|
+
const statusId = activityMessages.get(key);
|
|
515
|
+
if (text === undefined) {
|
|
516
|
+
if (!statusId) return;
|
|
517
|
+
try {
|
|
518
|
+
await messengerFetchJson(
|
|
519
|
+
`${messages}/${encodeURIComponent(statusId)}`,
|
|
520
|
+
{ method: "DELETE", headers: headers() },
|
|
521
|
+
signal,
|
|
522
|
+
deps.fetch,
|
|
523
|
+
);
|
|
524
|
+
} catch (error) {
|
|
525
|
+
// Keep the ID so the caller can retry; a missing message is already removed.
|
|
526
|
+
if (!(error instanceof MessengerHttpError && error.status === 404)) throw error;
|
|
527
|
+
}
|
|
528
|
+
activityMessages.delete(key);
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
const content = `⏳ ${text}`;
|
|
532
|
+
if (statusId) {
|
|
533
|
+
try {
|
|
534
|
+
// Edits re-parse mentions with default allowances unless allowed_mentions is repeated.
|
|
535
|
+
await messengerFetchJson(
|
|
536
|
+
`${messages}/${encodeURIComponent(statusId)}`,
|
|
537
|
+
{
|
|
538
|
+
method: "PATCH",
|
|
539
|
+
headers: headers(),
|
|
540
|
+
body: JSON.stringify({ content, allowed_mentions: { parse: [] } }),
|
|
541
|
+
},
|
|
542
|
+
signal,
|
|
543
|
+
deps.fetch,
|
|
544
|
+
);
|
|
545
|
+
return;
|
|
546
|
+
} catch (error) {
|
|
547
|
+
if (!(error instanceof MessengerHttpError && error.status === 404)) throw error;
|
|
548
|
+
activityMessages.delete(key);
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
const created = await messengerFetchJson(
|
|
552
|
+
messages,
|
|
553
|
+
{
|
|
554
|
+
method: "POST",
|
|
555
|
+
headers: headers(),
|
|
556
|
+
body: JSON.stringify({ content, allowed_mentions: { parse: [] }, flags: DISCORD_ACTIVITY_FLAGS }),
|
|
557
|
+
},
|
|
558
|
+
signal,
|
|
559
|
+
deps.fetch,
|
|
560
|
+
);
|
|
561
|
+
if (!isRecord(created) || typeof created.id !== "string" || !created.id)
|
|
562
|
+
throw new Error("Discord activity message returned no ID");
|
|
563
|
+
activityMessages.set(key, created.id);
|
|
564
|
+
},
|
|
507
565
|
async send(message, text, signal) {
|
|
508
566
|
let files: { name: string; data: Uint8Array }[] = [];
|
|
509
567
|
if (config.artifactDir) {
|
package/src/messenger/gateway.ts
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
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;
|
|
@@ -7,6 +14,157 @@ const MAX_QUEUED_MESSAGES = 100;
|
|
|
7
14
|
const MAX_SEEN_MESSAGES = 10_000;
|
|
8
15
|
const STATUS_TIMEOUT_MS = 1000;
|
|
9
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
|
+
}
|
|
10
168
|
|
|
11
169
|
/** Include the sender even in shared threads: remote histories must not cross users. */
|
|
12
170
|
export function messengerConversationKey(message: MessengerMessage): string {
|
|
@@ -19,6 +177,24 @@ export function messengerConversationKey(message: MessengerMessage): string {
|
|
|
19
177
|
]);
|
|
20
178
|
}
|
|
21
179
|
|
|
180
|
+
/** Stable per-conversation route shared by the persisted session file and its session process. */
|
|
181
|
+
export function messengerSessionRoute(cwd: string, message: MessengerMessage): string {
|
|
182
|
+
return new Bun.CryptoHasher("sha256").update(JSON.stringify([cwd, messengerConversationKey(message)])).digest("hex");
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Scheduling heuristic, refined after `prepare`: a top-level server message on a prepare-capable adapter
|
|
187
|
+
* (Discord) starts its own reply thread, so it is never held behind queued work; every other message waits for
|
|
188
|
+
* earlier requests in its conversation. A follow-up arriving in that new thread is ordered behind the request
|
|
189
|
+
* that created it by the prepared-conversation lock in `processRequest`.
|
|
190
|
+
*/
|
|
191
|
+
function schedulingKey(message: MessengerMessage, adapter: MessengerAdapter): string {
|
|
192
|
+
if (adapter.prepare && !message.direct && !message.threadId && !message.parentChannelId) {
|
|
193
|
+
return JSON.stringify([message.platform, message.accountId, message.channelId, message.messageId]);
|
|
194
|
+
}
|
|
195
|
+
return messengerConversationKey(message);
|
|
196
|
+
}
|
|
197
|
+
|
|
22
198
|
export function isMessengerMessageAllowed(message: MessengerMessage, config: MessengerConfig): boolean {
|
|
23
199
|
const platform = config.platforms[message.platform];
|
|
24
200
|
return Boolean(
|
|
@@ -43,6 +219,8 @@ interface PendingMessage {
|
|
|
43
219
|
adapter: MessengerAdapter;
|
|
44
220
|
signal: AbortSignal;
|
|
45
221
|
resolve: () => void;
|
|
222
|
+
/** Requests sharing a key never run at the same time. */
|
|
223
|
+
key: string;
|
|
46
224
|
}
|
|
47
225
|
|
|
48
226
|
export interface MessengerGateway {
|
|
@@ -50,11 +228,24 @@ export interface MessengerGateway {
|
|
|
50
228
|
handle(message: MessengerMessage, signal?: AbortSignal): Promise<void>;
|
|
51
229
|
}
|
|
52
230
|
|
|
231
|
+
export interface MessengerGatewayOptions {
|
|
232
|
+
/** Minimum spacing between in-place activity edits for one request. */
|
|
233
|
+
activityIntervalMs?: number;
|
|
234
|
+
/**
|
|
235
|
+
* Requests processed at once (default 1). Requests of one prepared conversation still answer and deliver in
|
|
236
|
+
* order; above 1 the reply implementation must accept concurrent turns for different conversations.
|
|
237
|
+
*/
|
|
238
|
+
concurrency?: number;
|
|
239
|
+
}
|
|
240
|
+
|
|
53
241
|
export function createMessengerGateway(
|
|
54
242
|
config: MessengerConfig,
|
|
55
243
|
adapters: MessengerAdapter[],
|
|
56
244
|
reply: MessengerReply,
|
|
245
|
+
options: MessengerGatewayOptions = {},
|
|
57
246
|
): MessengerGateway {
|
|
247
|
+
const activityIntervalMs = Math.max(0, options.activityIntervalMs ?? ACTIVITY_INTERVAL_MS);
|
|
248
|
+
const concurrency = Math.max(1, Math.trunc(options.concurrency ?? 1));
|
|
58
249
|
const byPlatform = new Map(adapters.map(adapter => [adapter.platform, adapter]));
|
|
59
250
|
if (byPlatform.size !== adapters.length) throw new Error("Duplicate messenger adapter");
|
|
60
251
|
for (const platform of Object.keys(config.platforms)) {
|
|
@@ -63,84 +254,120 @@ export function createMessengerGateway(
|
|
|
63
254
|
}
|
|
64
255
|
const seen = new Set<string>();
|
|
65
256
|
const queue: PendingMessage[] = [];
|
|
257
|
+
const activeKeys = new Set<string>();
|
|
258
|
+
const turns = new Set<Promise<void>>();
|
|
259
|
+
/** Settles when the latest request of a prepared conversation has delivered its reply. */
|
|
260
|
+
const conversationTails = new Map<string, Promise<void>>();
|
|
66
261
|
const lifetime = new AbortController();
|
|
67
|
-
let draining: Promise<void> | undefined;
|
|
68
|
-
let workerActive = false;
|
|
69
262
|
let running = false;
|
|
70
263
|
let fatalError: MessengerFatalError | undefined;
|
|
71
264
|
let statusInFlight = 0;
|
|
72
265
|
|
|
73
|
-
async function
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
};
|
|
110
|
-
try {
|
|
111
|
-
if (signal.aborted) continue;
|
|
112
|
-
status("processing");
|
|
113
|
-
const destination = (await adapter.prepare?.(message, signal)) ?? message;
|
|
114
|
-
let text: string;
|
|
266
|
+
async function processRequest(pending: PendingMessage): Promise<void> {
|
|
267
|
+
const { message, adapter, signal, resolve } = pending;
|
|
268
|
+
let failed = false;
|
|
269
|
+
let activity = NO_ACTIVITY;
|
|
270
|
+
let release = (): void => {};
|
|
271
|
+
let tail: Promise<void> | undefined;
|
|
272
|
+
let conversation = "";
|
|
273
|
+
let statusChain = Promise.resolve();
|
|
274
|
+
const status = (state: "processing" | "success" | "failure"): void => {
|
|
275
|
+
if (!adapter.status) return;
|
|
276
|
+
statusChain = statusChain.then(async () => {
|
|
277
|
+
if (signal.aborted || statusInFlight >= MAX_STATUS_IN_FLIGHT) return;
|
|
278
|
+
const timeout = new AbortController();
|
|
279
|
+
const statusSignal = AbortSignal.any([signal, timeout.signal]);
|
|
280
|
+
const stopped = Promise.withResolvers<void>();
|
|
281
|
+
const onAbort = (): void => stopped.resolve();
|
|
282
|
+
statusSignal.addEventListener("abort", onAbort, { once: true });
|
|
283
|
+
statusInFlight++;
|
|
284
|
+
const operation = Promise.resolve().then(() => adapter.status!(message, state, statusSignal));
|
|
285
|
+
void operation.then(
|
|
286
|
+
() => {
|
|
287
|
+
statusInFlight--;
|
|
288
|
+
},
|
|
289
|
+
() => {
|
|
290
|
+
statusInFlight--;
|
|
291
|
+
},
|
|
292
|
+
);
|
|
293
|
+
const timer = setTimeout(() => timeout.abort(), STATUS_TIMEOUT_MS);
|
|
115
294
|
try {
|
|
116
|
-
|
|
117
|
-
} catch
|
|
118
|
-
failed
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
lifetime.abort();
|
|
123
|
-
continue;
|
|
124
|
-
}
|
|
125
|
-
if (signal.aborted) continue;
|
|
126
|
-
logger.warn("Messenger agent turn failed", { platform: message.platform });
|
|
127
|
-
text = "The request could not be completed. Please try again.";
|
|
128
|
-
}
|
|
129
|
-
if (!signal.aborted && text.trim()) {
|
|
130
|
-
// A failed send may already have delivered a chunk: never retry the entire reply.
|
|
131
|
-
await adapter.send(destination, text, signal);
|
|
295
|
+
await Promise.race([operation, stopped.promise]);
|
|
296
|
+
} catch {
|
|
297
|
+
if (!signal.aborted) logger.warn("Messenger status update failed", { platform: message.platform });
|
|
298
|
+
} finally {
|
|
299
|
+
clearTimeout(timer);
|
|
300
|
+
statusSignal.removeEventListener("abort", onAbort);
|
|
132
301
|
}
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
302
|
+
});
|
|
303
|
+
};
|
|
304
|
+
try {
|
|
305
|
+
if (signal.aborted) return;
|
|
306
|
+
status("processing");
|
|
307
|
+
const destination = (await adapter.prepare?.(message, signal)) ?? message;
|
|
308
|
+
// Answer and deliver in arrival order per prepared conversation, so a follow-up in a new thread can
|
|
309
|
+
// neither overtake nor interleave with the reply of the request that created it.
|
|
310
|
+
conversation = messengerConversationKey(destination);
|
|
311
|
+
const previous = conversationTails.get(conversation);
|
|
312
|
+
const done = Promise.withResolvers<void>();
|
|
313
|
+
release = done.resolve;
|
|
314
|
+
tail = done.promise;
|
|
315
|
+
conversationTails.set(conversation, tail);
|
|
316
|
+
await previous;
|
|
317
|
+
activity = createActivityReporter(adapter, destination, signal, activityIntervalMs);
|
|
318
|
+
let text: string;
|
|
319
|
+
try {
|
|
320
|
+
text = await reply(destination, signal, activity.report);
|
|
321
|
+
} catch (error) {
|
|
322
|
+
failed = true;
|
|
323
|
+
if (error instanceof MessengerFatalError) {
|
|
324
|
+
fatalError = error;
|
|
137
325
|
status("failure");
|
|
326
|
+
lifetime.abort();
|
|
327
|
+
return;
|
|
138
328
|
}
|
|
329
|
+
if (signal.aborted) return;
|
|
330
|
+
logger.warn("Messenger agent turn failed", { platform: message.platform });
|
|
331
|
+
text = "The request could not be completed. Please try again.";
|
|
139
332
|
} finally {
|
|
140
|
-
|
|
333
|
+
// Retire the status line alongside reply delivery rather than delaying the reply on it.
|
|
334
|
+
void activity.close();
|
|
335
|
+
}
|
|
336
|
+
if (!signal.aborted && text.trim()) {
|
|
337
|
+
// A failed send may already have delivered a chunk: never retry the entire reply.
|
|
338
|
+
await adapter.send(destination, text, signal);
|
|
141
339
|
}
|
|
340
|
+
if (!signal.aborted) status(failed ? "failure" : "success");
|
|
341
|
+
} catch {
|
|
342
|
+
if (!signal.aborted) {
|
|
343
|
+
logger.warn("Messenger reply delivery failed", { platform: message.platform });
|
|
344
|
+
status("failure");
|
|
345
|
+
}
|
|
346
|
+
} finally {
|
|
347
|
+
await activity.close();
|
|
348
|
+
release();
|
|
349
|
+
if (tail && conversationTails.get(conversation) === tail) conversationTails.delete(conversation);
|
|
350
|
+
resolve();
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** Start queued requests in arrival order while capacity allows, skipping conversations already running. */
|
|
355
|
+
function schedule(): void {
|
|
356
|
+
for (let index = 0; index < queue.length && turns.size < concurrency; ) {
|
|
357
|
+
const pending = queue[index]!;
|
|
358
|
+
if (activeKeys.has(pending.key)) {
|
|
359
|
+
index++;
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
queue.splice(index, 1);
|
|
363
|
+
activeKeys.add(pending.key);
|
|
364
|
+
const turn = processRequest(pending).finally(() => {
|
|
365
|
+
activeKeys.delete(pending.key);
|
|
366
|
+
turns.delete(turn);
|
|
367
|
+
schedule();
|
|
368
|
+
});
|
|
369
|
+
turns.add(turn);
|
|
142
370
|
}
|
|
143
|
-
workerActive = false;
|
|
144
371
|
}
|
|
145
372
|
|
|
146
373
|
function handle(message: MessengerMessage, signal?: AbortSignal): Promise<void> {
|
|
@@ -155,11 +382,8 @@ export function createMessengerGateway(
|
|
|
155
382
|
if (oldest !== undefined) seen.delete(oldest);
|
|
156
383
|
}
|
|
157
384
|
const { promise, resolve } = Promise.withResolvers<void>();
|
|
158
|
-
queue.push({ message, adapter, signal: effectiveSignal, resolve });
|
|
159
|
-
|
|
160
|
-
workerActive = true;
|
|
161
|
-
draining = drain();
|
|
162
|
-
}
|
|
385
|
+
queue.push({ message, adapter, signal: effectiveSignal, resolve, key: schedulingKey(message, adapter) });
|
|
386
|
+
schedule();
|
|
163
387
|
return promise;
|
|
164
388
|
}
|
|
165
389
|
|
|
@@ -191,7 +415,7 @@ export function createMessengerGateway(
|
|
|
191
415
|
} finally {
|
|
192
416
|
lifetime.abort();
|
|
193
417
|
await Promise.allSettled(starts);
|
|
194
|
-
await
|
|
418
|
+
while (turns.size > 0) await Promise.allSettled(turns);
|
|
195
419
|
signal.removeEventListener("abort", abort);
|
|
196
420
|
}
|
|
197
421
|
},
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { AssistantMessage, UserMessage } from "jeopi-ai";
|
|
2
2
|
import type { InteractiveModeContext } from "../modes/types";
|
|
3
|
+
import { createMessengerActivityTracker } from "./activity";
|
|
3
4
|
import { messengerArtifactPrompt } from "./artifacts";
|
|
4
5
|
import { loadMessengerConfig, validateMessengerEnvironment } from "./config";
|
|
5
6
|
import { createDiscordAdapter, type DiscordTransportDeps } from "./discord";
|
|
@@ -156,7 +157,7 @@ export function createLiveDiscordBridge(
|
|
|
156
157
|
let localCapture: object | undefined;
|
|
157
158
|
let close: () => Promise<void>;
|
|
158
159
|
let revoke: () => Promise<void>;
|
|
159
|
-
const reply: MessengerReply = async (message, signal) => {
|
|
160
|
+
const reply: MessengerReply = async (message, signal, onActivity) => {
|
|
160
161
|
if (closed || signal?.aborted) throw new Error(LIVE_UNAVAILABLE_MESSAGE);
|
|
161
162
|
if (!sameSession(ctx, attachedSession, sessionId, sessionFile)) throw new Error(LIVE_UNAVAILABLE_MESSAGE);
|
|
162
163
|
if (!isMessengerMessageAllowed(message, scopedConfig)) throw new Error(LIVE_UNAVAILABLE_MESSAGE);
|
|
@@ -190,8 +191,13 @@ export function createLiveDiscordBridge(
|
|
|
190
191
|
void revoke();
|
|
191
192
|
};
|
|
192
193
|
operationSignal.addEventListener("abort", onAbort, { once: true });
|
|
194
|
+
const track = onActivity && createMessengerActivityTracker(onActivity);
|
|
193
195
|
const unsubscribe = attachedSession.subscribe(event => {
|
|
194
|
-
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
|
+
}
|
|
195
201
|
if (event.message.role === "user") {
|
|
196
202
|
const text = userMessageText(event.message);
|
|
197
203
|
if (!capture.promptSeen && text === capture.promptText) {
|
|
@@ -265,6 +271,7 @@ export function createLiveDiscordBridge(
|
|
|
265
271
|
const adapterFactory = dependencies.createAdapter ?? createDiscordAdapter;
|
|
266
272
|
const gatewayFactory = dependencies.createGateway ?? createMessengerGateway;
|
|
267
273
|
const adapter = adapterFactory(discordConfig, dependencies.env, dependencies.transport);
|
|
274
|
+
const activity = adapter.activity;
|
|
268
275
|
const guardedAdapter: MessengerAdapter = {
|
|
269
276
|
...adapter,
|
|
270
277
|
send: async (message, text, signal) => {
|
|
@@ -277,6 +284,13 @@ export function createLiveDiscordBridge(
|
|
|
277
284
|
}
|
|
278
285
|
await adapter.send(message, text, sendSignal);
|
|
279
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
|
+
}),
|
|
280
294
|
};
|
|
281
295
|
const setRelayDestination = (channelId: string | undefined): void => {
|
|
282
296
|
if (closed || !sameSession(ctx, attachedSession, sessionId, sessionFile))
|