jeo-code 0.12.12 → 0.12.15
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 +47 -0
- package/README.ja.md +33 -9
- package/README.ko.md +33 -9
- package/README.md +33 -9
- package/README.zh.md +33 -9
- package/package.json +4 -2
- package/src/agent/browser-tool.ts +9 -1
- package/src/agent/config-schema.ts +8 -0
- package/src/agent/context-files.ts +19 -0
- package/src/agent/engine.ts +25 -6
- package/src/agent/eval-tool.ts +6 -0
- package/src/agent/goal-verifier.ts +30 -6
- package/src/agent/hooks.ts +52 -4
- package/src/agent/job-registry.ts +110 -18
- package/src/agent/job-tool.ts +43 -9
- package/src/agent/loop-guards.ts +17 -2
- package/src/agent/memory.ts +151 -112
- package/src/agent/monitor-registry.ts +142 -30
- package/src/agent/monitor-tool.ts +34 -6
- package/src/agent/notify/discord-daemon.ts +135 -42
- package/src/agent/notify/session-endpoint.ts +27 -5
- package/src/agent/sandbox/assets.d.ts +4 -0
- package/src/agent/sandbox/authority.ts +295 -0
- package/src/agent/sandbox/bundle.ts +37 -0
- package/src/agent/sandbox/control.ts +49 -0
- package/src/agent/sandbox/docker.ts +283 -0
- package/src/agent/sandbox/hands-source.text +14548 -0
- package/src/agent/sandbox/hands-worker.ts +168 -0
- package/src/agent/sandbox/policy.ts +155 -0
- package/src/agent/sandbox/rpc.ts +90 -0
- package/src/agent/sandbox/supervisor.ts +210 -0
- package/src/agent/sandbox/types.ts +77 -0
- package/src/agent/shell-lease.ts +190 -0
- package/src/agent/state.ts +167 -0
- package/src/agent/subagents.ts +4 -3
- package/src/agent/task-tool.ts +8 -0
- package/src/agent/todo-tool.ts +30 -1
- package/src/agent/tool-schemas.ts +13 -0
- package/src/ai/model-discovery.ts +12 -0
- package/src/ai/providers/anthropic.ts +27 -2
- package/src/ai/providers/antigravity.ts +10 -2
- package/src/ai/providers/gemini.ts +20 -16
- package/src/ai/providers/ollama.ts +14 -0
- package/src/ai/providers/openai-responses.ts +22 -5
- package/src/ai/providers/openai.ts +23 -40
- package/src/cli/runner.ts +3 -0
- package/src/commands/approve.ts +37 -13
- package/src/commands/launch/flags.ts +2 -1
- package/src/commands/launch/jobs-slash.ts +1 -1
- package/src/commands/launch.ts +261 -35
- package/src/commands/messenger.ts +105 -39
- package/src/commands/notify.ts +2 -2
- package/src/commands/team.ts +44 -12
- package/src/commands/ultragoal.ts +6 -16
- package/src/messenger/activity.ts +2 -1
- package/src/messenger/config.ts +51 -9
- package/src/messenger/discord-setup.ts +122 -20
- package/src/messenger/discord.ts +39 -52
- package/src/messenger/gateway.ts +33 -10
- package/src/messenger/model-selection.ts +101 -0
- package/src/messenger/session-pool.ts +189 -35
- package/src/messenger/types.ts +3 -2
- package/src/skills/catalog.ts +207 -0
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
import * as fs from "node:fs/promises";
|
|
2
2
|
import * as path from "node:path";
|
|
3
|
-
import { loadMessengerConfig, resolveMessengerToken, validateMessengerEnvironment } from "./config";
|
|
3
|
+
import { loadMessengerConfig, parseMessengerSandbox, resolveMessengerToken, validateMessengerEnvironment } from "./config";
|
|
4
4
|
import { DISCORD_API, DISCORD_GATEWAY_INTENTS, discordGatewayUrl } from "./discord";
|
|
5
5
|
import { MessengerHttpError, messengerFetchJson } from "./http";
|
|
6
6
|
import { installMessengerService, messengerServicePlan } from "./service";
|
|
7
7
|
import { isEnoent, isRecord } from "./types";
|
|
8
|
+
import { notifyDir } from "../agent/notify/paths";
|
|
8
9
|
|
|
9
10
|
export const DISCORD_TOKEN_ENV = "JEO_DISCORD_BOT_TOKEN";
|
|
10
11
|
const TOKEN_FILE = "discord-token";
|
|
11
|
-
const
|
|
12
|
+
const RUN_SCRIPT_POSIX = "run-discord.sh";
|
|
13
|
+
const RUN_SCRIPT_WINDOWS = "run-discord.cmd";
|
|
12
14
|
const MESSAGE_CONTENT = 1 << 18;
|
|
13
15
|
const MESSAGE_CONTENT_LIMITED = 1 << 19;
|
|
14
16
|
/** The only application flags a bot token may change: the limited presence, member, and message intents. */
|
|
@@ -32,6 +34,28 @@ const INVITE_POLL_MS = 3_000;
|
|
|
32
34
|
const GATEWAY_PROBE_MS = 20_000;
|
|
33
35
|
const DM_TEST_MESSAGE = "jeo messenger is set up for this bot. Reply here to test the connection.";
|
|
34
36
|
|
|
37
|
+
function throwIfAborted(signal?: AbortSignal): void {
|
|
38
|
+
if (signal?.aborted) throw new Error("Discord setup cancelled");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function interruptibleSleep(sleep: (ms: number) => Promise<void>, ms: number, signal?: AbortSignal): Promise<void> {
|
|
42
|
+
if (!signal) return sleep(ms);
|
|
43
|
+
if (signal.aborted) throw new Error("Discord setup cancelled");
|
|
44
|
+
let cancel: (() => void) | undefined;
|
|
45
|
+
try {
|
|
46
|
+
await Promise.race([
|
|
47
|
+
sleep(ms),
|
|
48
|
+
new Promise<never>((_resolve, reject) => {
|
|
49
|
+
cancel = () => reject(new Error("Discord setup cancelled"));
|
|
50
|
+
signal.addEventListener("abort", cancel, { once: true });
|
|
51
|
+
if (signal.aborted) cancel();
|
|
52
|
+
}),
|
|
53
|
+
]);
|
|
54
|
+
} finally {
|
|
55
|
+
if (cancel) signal.removeEventListener("abort", cancel);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
35
59
|
export interface DiscordSetupIO {
|
|
36
60
|
readonly interactive: boolean;
|
|
37
61
|
say(line: string): void;
|
|
@@ -60,6 +84,11 @@ export interface DiscordSetupDeps {
|
|
|
60
84
|
/** argv that starts this jeo build; defaults to the running runtime and entry. */
|
|
61
85
|
jeoCommand?: string[];
|
|
62
86
|
installService?: typeof installMessengerService;
|
|
87
|
+
/** Cancels invite polling and the remaining setup work (the CLI wires Ctrl+C here). */
|
|
88
|
+
signal?: AbortSignal;
|
|
89
|
+
/** Injectable platform/path separator for cross-platform release tests. */
|
|
90
|
+
platform?: NodeJS.Platform;
|
|
91
|
+
pathDelimiter?: string;
|
|
63
92
|
}
|
|
64
93
|
|
|
65
94
|
export interface DiscordSetupResult {
|
|
@@ -89,18 +118,23 @@ export interface RequestChannel {
|
|
|
89
118
|
|
|
90
119
|
type DiscordApiFn = (method: string, route: string, body?: unknown) => Promise<unknown>;
|
|
91
120
|
|
|
92
|
-
function discordApi(token: string, fetchImpl: typeof fetch): DiscordApiFn {
|
|
93
|
-
return (method, route, body) =>
|
|
94
|
-
|
|
121
|
+
function discordApi(token: string, fetchImpl: typeof fetch, signal?: AbortSignal): DiscordApiFn {
|
|
122
|
+
return async (method, route, body) => {
|
|
123
|
+
throwIfAborted(signal);
|
|
124
|
+
const result = await messengerFetchJson(
|
|
95
125
|
`${DISCORD_API}${route}`,
|
|
96
126
|
{
|
|
97
127
|
method,
|
|
128
|
+
signal,
|
|
98
129
|
headers: { Authorization: `Bot ${token}`, "Content-Type": "application/json" },
|
|
99
130
|
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
100
131
|
},
|
|
101
132
|
undefined,
|
|
102
133
|
fetchImpl,
|
|
103
134
|
);
|
|
135
|
+
throwIfAborted(signal);
|
|
136
|
+
return result;
|
|
137
|
+
};
|
|
104
138
|
}
|
|
105
139
|
|
|
106
140
|
function str(value: unknown): string | undefined {
|
|
@@ -201,15 +235,21 @@ export async function waitForDiscordGuilds(
|
|
|
201
235
|
io: DiscordSetupIO,
|
|
202
236
|
sleep: (ms: number) => Promise<void>,
|
|
203
237
|
waitMs: number,
|
|
238
|
+
signal?: AbortSignal,
|
|
204
239
|
): Promise<Record<string, unknown>[]> {
|
|
240
|
+
throwIfAborted(signal);
|
|
205
241
|
let guilds = records(await api("GET", "/users/@me/guilds"));
|
|
242
|
+
throwIfAborted(signal);
|
|
206
243
|
if (guilds.length) return guilds;
|
|
207
244
|
if (!io.interactive) throw new Error(`Invite the bot to a server, then rerun: ${inviteUrl}`);
|
|
208
245
|
io.say(`Invite the bot to your server (choose the server and authorize):\n ${inviteUrl}`);
|
|
209
246
|
io.say("Waiting for the bot to join…");
|
|
210
247
|
for (const deadline = Date.now() + waitMs; Date.now() < deadline; ) {
|
|
211
|
-
|
|
248
|
+
throwIfAborted(signal);
|
|
249
|
+
await interruptibleSleep(sleep, Math.min(INVITE_POLL_MS, Math.max(0, deadline - Date.now())), signal);
|
|
250
|
+
throwIfAborted(signal);
|
|
212
251
|
guilds = records(await api("GET", "/users/@me/guilds"));
|
|
252
|
+
throwIfAborted(signal);
|
|
213
253
|
if (guilds.length) return guilds;
|
|
214
254
|
}
|
|
215
255
|
throw new Error(`The bot has not joined a server yet; invite it, then rerun: ${inviteUrl}`);
|
|
@@ -363,8 +403,11 @@ export async function probeDiscordGateway(
|
|
|
363
403
|
api: DiscordApiFn,
|
|
364
404
|
token: string,
|
|
365
405
|
WebSocketImpl: typeof WebSocket,
|
|
406
|
+
signal?: AbortSignal,
|
|
366
407
|
): Promise<void> {
|
|
408
|
+
throwIfAborted(signal);
|
|
367
409
|
const discovery = await api("GET", "/gateway/bot");
|
|
410
|
+
throwIfAborted(signal);
|
|
368
411
|
const url = discordGatewayUrl(isRecord(discovery) ? discovery.url : undefined);
|
|
369
412
|
const result = Promise.withResolvers<void>();
|
|
370
413
|
const socket = new WebSocketImpl(url);
|
|
@@ -372,7 +415,11 @@ export async function probeDiscordGateway(
|
|
|
372
415
|
() => result.reject(new Error("Discord Gateway did not confirm the bot session in time")),
|
|
373
416
|
GATEWAY_PROBE_MS,
|
|
374
417
|
);
|
|
418
|
+
const cancel = () => result.reject(new Error("Discord setup cancelled"));
|
|
419
|
+
signal?.addEventListener("abort", cancel, { once: true });
|
|
420
|
+
if (signal?.aborted) cancel();
|
|
375
421
|
socket.addEventListener("message", (event: MessageEvent) => {
|
|
422
|
+
if (signal?.aborted) return;
|
|
376
423
|
let packet: unknown;
|
|
377
424
|
try {
|
|
378
425
|
packet = typeof event.data === "string" ? JSON.parse(event.data) : undefined;
|
|
@@ -404,8 +451,10 @@ export async function probeDiscordGateway(
|
|
|
404
451
|
socket.addEventListener("error", () => result.reject(new Error("Discord Gateway connection failed")));
|
|
405
452
|
try {
|
|
406
453
|
await result.promise;
|
|
454
|
+
throwIfAborted(signal);
|
|
407
455
|
} finally {
|
|
408
456
|
clearTimeout(timer);
|
|
457
|
+
signal?.removeEventListener("abort", cancel);
|
|
409
458
|
try {
|
|
410
459
|
socket.close(1000);
|
|
411
460
|
} catch {
|
|
@@ -434,6 +483,18 @@ exec ${command.map(quote).join(" ")} messenger run --config "$dir/${configName}"
|
|
|
434
483
|
`;
|
|
435
484
|
}
|
|
436
485
|
|
|
486
|
+
/** The Windows launcher inherits PATH at launch; the token remains in the private sidecar. */
|
|
487
|
+
export function discordRunCommand(command: string[], configName: string, tokenEnv: string): string {
|
|
488
|
+
if (!/^[A-Za-z_]/u.test(tokenEnv) || /[^A-Za-z0-9_]/u.test(tokenEnv)) throw new Error("discord.tokenEnv must name an environment variable");
|
|
489
|
+
const literal = (value: string, label: string): string => {
|
|
490
|
+
if (/["\r\n]/.test(value)) throw new Error(`Windows launcher ${label} must not contain quotes or newlines`);
|
|
491
|
+
if (/[^\x00-\x7f]/.test(value)) throw new Error(`Windows launcher ${label} must contain only ASCII characters; use ASCII paths`);
|
|
492
|
+
return value.replace(/%/g, "%%");
|
|
493
|
+
};
|
|
494
|
+
const quote = (value: string): string => `"${literal(value, "command")}"`;
|
|
495
|
+
return `@echo off\r\nsetlocal DisableDelayedExpansion\r\nset /p "${tokenEnv}="<"%~dp0${TOKEN_FILE}"\r\n${command.map(quote).join(" ")} messenger run --config "%~dp0${literal(configName, "config name")}"\r\n`;
|
|
496
|
+
}
|
|
497
|
+
|
|
437
498
|
async function writePrivate(file: string, content: string, mode: number): Promise<void> {
|
|
438
499
|
const temporary = `${file}.${process.pid}.tmp`;
|
|
439
500
|
await Bun.write(temporary, content);
|
|
@@ -458,9 +519,14 @@ async function readExistingConfig(configPath: string): Promise<Record<string, un
|
|
|
458
519
|
throw new Error(`${configPath} exists but is not a JSON object; fix or move it before setup`);
|
|
459
520
|
}
|
|
460
521
|
|
|
461
|
-
function currentJeoCommand(): string[] {
|
|
462
|
-
|
|
463
|
-
|
|
522
|
+
export function currentJeoCommand(entry = Bun.main, execPath = process.execPath): string[] {
|
|
523
|
+
return entry === execPath || entry.startsWith("/$bunfs/") || entry.replaceAll("\\", "/").startsWith("B:/~BUN/") ? [execPath] : [execPath, entry];
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
export function messengerExecutablePath(execPath: string, inheritedPath: string | undefined, delimiter: string, platform: NodeJS.Platform): string {
|
|
527
|
+
const fallbackPath = platform === "win32" ? "" : "/usr/bin:/bin";
|
|
528
|
+
const runtimeDirectory = platform === "win32" ? path.win32.dirname(execPath) : path.dirname(execPath);
|
|
529
|
+
return [...new Set([runtimeDirectory, ...(inheritedPath ?? fallbackPath).split(delimiter).filter(Boolean)])].join(delimiter);
|
|
464
530
|
}
|
|
465
531
|
|
|
466
532
|
/** Create or repair a Discord messenger profile with as few manual steps as Discord allows. */
|
|
@@ -473,20 +539,47 @@ export async function setupDiscordMessenger(
|
|
|
473
539
|
const configPath = path.resolve(options.configPath);
|
|
474
540
|
const directory = path.dirname(configPath);
|
|
475
541
|
const tokenPath = path.join(directory, TOKEN_FILE);
|
|
476
|
-
const
|
|
542
|
+
const platform = deps.platform ?? process.platform;
|
|
543
|
+
throwIfAborted(deps.signal);
|
|
544
|
+
if (options.service && platform !== "darwin" && platform !== "linux") {
|
|
545
|
+
throw new Error("Background services are supported only on macOS and Linux");
|
|
546
|
+
}
|
|
547
|
+
const runScript = path.join(directory, platform === "win32" ? RUN_SCRIPT_WINDOWS : RUN_SCRIPT_POSIX);
|
|
548
|
+
const delimiter = deps.pathDelimiter ?? path.delimiter;
|
|
549
|
+
const envPath = messengerExecutablePath(process.execPath, env.PATH, delimiter, platform);
|
|
550
|
+
const command = deps.jeoCommand ?? currentJeoCommand();
|
|
551
|
+
const existing = await readExistingConfig(configPath);
|
|
552
|
+
const sandbox = existing?.sandbox !== undefined ? parseMessengerSandbox(existing.sandbox) : parseMessengerSandbox({
|
|
553
|
+
image: await io.ask("Existing local Linux sandbox image (no image will be pulled): "),
|
|
554
|
+
limits: {
|
|
555
|
+
memoryBytes: Number(await io.ask("Sandbox memory limit in bytes: ")),
|
|
556
|
+
pids: Number(await io.ask("Sandbox process limit: ")),
|
|
557
|
+
cpus: Number(await io.ask("Sandbox CPU limit: ")),
|
|
558
|
+
},
|
|
559
|
+
});
|
|
560
|
+
if (existing && Object.hasOwn(existing, "independentSessions")) {
|
|
561
|
+
delete existing.independentSessions;
|
|
562
|
+
io.say("Removed obsolete independentSessions setting. Discord threads now keep one persistent session.");
|
|
563
|
+
}
|
|
564
|
+
if (platform === "win32") discordRunCommand(command, path.basename(configPath), DISCORD_TOKEN_ENV);
|
|
477
565
|
|
|
478
566
|
const token = await resolveToken(env, tokenPath, io);
|
|
479
|
-
|
|
567
|
+
throwIfAborted(deps.signal);
|
|
568
|
+
const api = discordApi(token, deps.fetch ?? fetch, deps.signal);
|
|
480
569
|
const bot = await inspectDiscordBot(api);
|
|
570
|
+
throwIfAborted(deps.signal);
|
|
481
571
|
io.say(`Bot ${bot.username} (${bot.id}), application ${bot.applicationId}`);
|
|
482
572
|
await ensureMessageContentIntent(api, bot, io);
|
|
573
|
+
throwIfAborted(deps.signal);
|
|
483
574
|
|
|
484
575
|
await fs.mkdir(directory, { recursive: true, mode: 0o700 });
|
|
576
|
+
throwIfAborted(deps.signal);
|
|
485
577
|
await writePrivate(tokenPath, `${token}\n`, 0o600);
|
|
578
|
+
throwIfAborted(deps.signal);
|
|
486
579
|
|
|
487
580
|
const inviteUrl = discordInviteUrl(bot.applicationId);
|
|
488
|
-
const guilds = await waitForDiscordGuilds(api, inviteUrl, io, deps.sleep ?? Bun.sleep, options.inviteWaitMs ?? 300_000);
|
|
489
|
-
|
|
581
|
+
const guilds = await waitForDiscordGuilds(api, inviteUrl, io, deps.sleep ?? Bun.sleep, options.inviteWaitMs ?? 300_000, deps.signal);
|
|
582
|
+
throwIfAborted(deps.signal);
|
|
490
583
|
const platforms = isRecord(existing?.platforms) ? existing.platforms : {};
|
|
491
584
|
const previous = isRecord(platforms.discord) ? platforms.discord : {};
|
|
492
585
|
const previousList = (key: string): string[] | undefined => {
|
|
@@ -499,14 +592,17 @@ export async function setupDiscordMessenger(
|
|
|
499
592
|
let channelIds: string[];
|
|
500
593
|
if (options.channelIds?.length || !previousList("allowedChannelIds")) {
|
|
501
594
|
const channels = await selectDiscordChannels(api, bot, guilds, options.channelIds, inviteUrl, io);
|
|
595
|
+
throwIfAborted(deps.signal);
|
|
502
596
|
for (const channel of channels) io.say(`Request channel: #${channel.name} in ${channel.guildName}`);
|
|
503
597
|
channelIds = channels.map(channel => channel.id);
|
|
504
598
|
} else {
|
|
505
599
|
channelIds = previousList("allowedChannelIds") as string[];
|
|
506
600
|
}
|
|
507
601
|
const dmChannel = options.dmTest === false ? undefined : await openOwnerDm(api, userIds[0]!, io);
|
|
602
|
+
throwIfAborted(deps.signal);
|
|
508
603
|
const allowedChannelIds = [...new Set([...channelIds, ...(dmChannel ? [dmChannel] : [])])];
|
|
509
604
|
const tokenEnv = str(previous.tokenEnv) ?? DISCORD_TOKEN_ENV;
|
|
605
|
+
if (platform === "win32") discordRunCommand(command, path.basename(configPath), tokenEnv);
|
|
510
606
|
|
|
511
607
|
const discord: Record<string, unknown> = { ...previous, tokenEnv, allowedUserIds: userIds, allowedChannelIds };
|
|
512
608
|
if (options.artifacts) discord.artifactDir = "./artifacts";
|
|
@@ -514,24 +610,26 @@ export async function setupDiscordMessenger(
|
|
|
514
610
|
const config = {
|
|
515
611
|
...existing,
|
|
516
612
|
cwd: str(existing?.cwd) ?? "./workspace",
|
|
517
|
-
sessionDir: str(existing?.sessionDir) ?? "
|
|
613
|
+
sessionDir: str(existing?.sessionDir) ?? path.join(notifyDir(), "messenger-sessions"),
|
|
614
|
+
sandbox,
|
|
518
615
|
toolNames: options.toolNames ?? (Array.isArray(existing?.toolNames) ? existing.toolNames : defaultTools),
|
|
519
|
-
independentSessions: existing?.independentSessions !== undefined ? Boolean(existing.independentSessions) : true,
|
|
520
616
|
platforms: { ...platforms, discord },
|
|
521
617
|
};
|
|
522
618
|
for (const relative of [config.cwd, config.sessionDir, discord.artifactDir]) {
|
|
523
619
|
if (typeof relative === "string")
|
|
524
620
|
await fs.mkdir(path.resolve(directory, relative), { recursive: true, mode: 0o700 });
|
|
621
|
+
throwIfAborted(deps.signal);
|
|
525
622
|
}
|
|
526
623
|
await writePrivate(configPath, `${JSON.stringify(config, null, 2)}\n`, 0o600);
|
|
527
|
-
|
|
528
|
-
":",
|
|
529
|
-
);
|
|
624
|
+
throwIfAborted(deps.signal);
|
|
530
625
|
await writePrivate(
|
|
531
626
|
runScript,
|
|
532
|
-
|
|
627
|
+
platform === "win32"
|
|
628
|
+
? discordRunCommand(command, path.basename(configPath), tokenEnv)
|
|
629
|
+
: discordRunScript(command, path.basename(configPath), tokenEnv, envPath),
|
|
533
630
|
0o700,
|
|
534
631
|
);
|
|
632
|
+
throwIfAborted(deps.signal);
|
|
535
633
|
// The same loader `messenger run` uses must accept what was written.
|
|
536
634
|
const written = await loadMessengerConfig(configPath);
|
|
537
635
|
validateMessengerEnvironment(
|
|
@@ -539,12 +637,14 @@ export async function setupDiscordMessenger(
|
|
|
539
637
|
{ [tokenEnv]: token },
|
|
540
638
|
);
|
|
541
639
|
|
|
542
|
-
await probeDiscordGateway(api, token, deps.WebSocket ?? WebSocket);
|
|
640
|
+
await probeDiscordGateway(api, token, deps.WebSocket ?? WebSocket, deps.signal);
|
|
641
|
+
throwIfAborted(deps.signal);
|
|
543
642
|
io.say("Discord Gateway accepted the bot session and intents.");
|
|
544
643
|
|
|
545
644
|
let service: string | undefined;
|
|
546
645
|
if (options.service) {
|
|
547
646
|
service = await (deps.installService ?? installMessengerService)(messengerServicePlan(directory, runScript));
|
|
647
|
+
throwIfAborted(deps.signal);
|
|
548
648
|
io.say(`Gateway running as ${service}`);
|
|
549
649
|
}
|
|
550
650
|
if (dmChannel) {
|
|
@@ -555,8 +655,10 @@ export async function setupDiscordMessenger(
|
|
|
555
655
|
io.say(
|
|
556
656
|
"Could not send the test DM (the owner may block DMs from server members); server requests still work.",
|
|
557
657
|
);
|
|
658
|
+
throwIfAborted(deps.signal);
|
|
558
659
|
}
|
|
559
660
|
}
|
|
661
|
+
throwIfAborted(deps.signal);
|
|
560
662
|
return {
|
|
561
663
|
configPath,
|
|
562
664
|
botName: bot.username,
|
package/src/messenger/discord.ts
CHANGED
|
@@ -335,16 +335,16 @@ export function createDiscordAdapter(
|
|
|
335
335
|
): MessengerAdapter {
|
|
336
336
|
const token = resolveMessengerToken(config.tokenEnv, env)?.trim();
|
|
337
337
|
const clock = deps.clock ?? defaultClock;
|
|
338
|
-
const createdThreads = new Map<string, { parentChannelId: string
|
|
339
|
-
const rememberThread = (threadId: string, parentChannelId: string
|
|
340
|
-
createdThreads.set(threadId, { parentChannelId
|
|
338
|
+
const createdThreads = new Map<string, { parentChannelId: string }>();
|
|
339
|
+
const rememberThread = (threadId: string, parentChannelId: string): void => {
|
|
340
|
+
createdThreads.set(threadId, { parentChannelId });
|
|
341
341
|
if (createdThreads.size > 10_000) {
|
|
342
342
|
const oldest = createdThreads.keys().next().value;
|
|
343
343
|
if (oldest !== undefined) createdThreads.delete(oldest);
|
|
344
344
|
}
|
|
345
345
|
};
|
|
346
|
+
const normalizationTails = new Map<string, Promise<void>>();
|
|
346
347
|
const activityMessages = new Map<string, string>();
|
|
347
|
-
const activityHistory = new Map<string, string[]>();
|
|
348
348
|
const headers = (): Record<string, string> => {
|
|
349
349
|
if (!token) throw new DiscordConfigurationError("Discord bot token is missing");
|
|
350
350
|
return { Authorization: `Bot ${token}`, "Content-Type": "application/json" };
|
|
@@ -387,57 +387,50 @@ export function createDiscordAdapter(
|
|
|
387
387
|
initialUrl = discordGatewayUrl(gateway.url);
|
|
388
388
|
}
|
|
389
389
|
const deliver = async (message: MessengerMessage): Promise<void> => {
|
|
390
|
-
const
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
390
|
+
const previous = normalizationTails.get(message.channelId) ?? Promise.resolve();
|
|
391
|
+
const ready = Promise.withResolvers<void>();
|
|
392
|
+
const tail = previous.then(() => ready.promise);
|
|
393
|
+
normalizationTails.set(message.channelId, tail);
|
|
394
|
+
const forward = (destination: MessengerMessage): Promise<void> => {
|
|
395
|
+
ready.resolve();
|
|
396
|
+
return onMessage(destination);
|
|
397
|
+
};
|
|
398
|
+
await previous;
|
|
399
|
+
try {
|
|
400
|
+
if (signal.aborted) return;
|
|
401
|
+
const thread = createdThreads.get(message.channelId);
|
|
402
|
+
if (thread && config.allowedUserIds.includes(message.senderId)) {
|
|
403
|
+
await forward({ ...message, threadId: message.channelId, parentChannelId: thread.parentChannelId });
|
|
404
|
+
} else if (!message.direct && config.allowedUserIds.includes(message.senderId)) {
|
|
404
405
|
const channel = await messengerFetchJson(
|
|
405
406
|
`${DISCORD_API}/channels/${encodeURIComponent(message.channelId)}`,
|
|
406
|
-
{ headers: headers() },
|
|
407
|
-
signal,
|
|
408
|
-
deps.fetch,
|
|
407
|
+
{ headers: headers() }, signal, deps.fetch,
|
|
409
408
|
);
|
|
410
|
-
if (
|
|
411
|
-
|
|
412
|
-
channel.
|
|
413
|
-
typeof channel.type === "number" &&
|
|
414
|
-
[10, 11, 12].includes(channel.type) &&
|
|
415
|
-
typeof channel.parent_id === "string" &&
|
|
416
|
-
channel.parent_id
|
|
417
|
-
) {
|
|
409
|
+
if (!isRecord(channel) || channel.id !== message.channelId) return;
|
|
410
|
+
if (typeof channel.type === "number" && [10, 11, 12].includes(channel.type)) {
|
|
411
|
+
if (typeof channel.parent_id !== "string" || !channel.parent_id) return;
|
|
418
412
|
if (!message.mentioned) {
|
|
419
413
|
const originalId = typeof channel.name === "string" ? /^Request (\d{1,20})$/.exec(channel.name)?.[1] : undefined;
|
|
420
414
|
if (channel.owner_id !== accountId || !originalId ||
|
|
421
|
-
(!config.allowedChannelIds.includes(channel.parent_id) && !config.allowedChannelIds.includes(message.channelId))) return;
|
|
415
|
+
(config.allowedChannelIds && !config.allowedChannelIds.includes(channel.parent_id) && !config.allowedChannelIds.includes(message.channelId))) return;
|
|
422
416
|
const original = await messengerFetchJson(
|
|
423
417
|
`${DISCORD_API}/channels/${encodeURIComponent(channel.parent_id)}/messages/${originalId}`,
|
|
424
418
|
{ headers: headers() }, signal, deps.fetch,
|
|
425
419
|
);
|
|
426
420
|
if (!isRecord(original) || original.id !== originalId || original.channel_id !== channel.parent_id ||
|
|
427
|
-
!isRecord(original.author) || original.author.id !==
|
|
428
|
-
rememberThread(message.channelId, channel.parent_id, message.senderId);
|
|
421
|
+
!isRecord(original.author) || typeof original.author.id !== "string" || original.author.bot === true || original.webhook_id) return;
|
|
429
422
|
}
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
});
|
|
423
|
+
rememberThread(message.channelId, channel.parent_id);
|
|
424
|
+
await forward({ ...message, threadId: message.channelId, parentChannelId: channel.parent_id });
|
|
425
|
+
} else {
|
|
426
|
+
await forward(message);
|
|
435
427
|
}
|
|
436
|
-
}
|
|
437
|
-
|
|
428
|
+
} else {
|
|
429
|
+
await forward(message);
|
|
438
430
|
}
|
|
439
|
-
}
|
|
440
|
-
|
|
431
|
+
} finally {
|
|
432
|
+
ready.resolve();
|
|
433
|
+
if (normalizationTails.get(message.channelId) === tail) normalizationTails.delete(message.channelId);
|
|
441
434
|
}
|
|
442
435
|
};
|
|
443
436
|
await connectGateway(
|
|
@@ -480,7 +473,7 @@ export function createDiscordAdapter(
|
|
|
480
473
|
async prepare(message, signal) {
|
|
481
474
|
if (message.direct) return message;
|
|
482
475
|
if (message.threadId) {
|
|
483
|
-
if (message.parentChannelId) rememberThread(message.threadId, message.parentChannelId
|
|
476
|
+
if (message.parentChannelId) rememberThread(message.threadId, message.parentChannelId);
|
|
484
477
|
return message;
|
|
485
478
|
}
|
|
486
479
|
const channel = await messengerFetchJson(
|
|
@@ -494,7 +487,7 @@ export function createDiscordAdapter(
|
|
|
494
487
|
if ([10, 11, 12].includes(channel.type)) {
|
|
495
488
|
if (typeof channel.parent_id !== "string" || !channel.parent_id)
|
|
496
489
|
throw new Error("Discord thread parent unavailable");
|
|
497
|
-
rememberThread(message.channelId, channel.parent_id
|
|
490
|
+
rememberThread(message.channelId, channel.parent_id);
|
|
498
491
|
return { ...message, threadId: message.channelId, parentChannelId: channel.parent_id };
|
|
499
492
|
}
|
|
500
493
|
if (channel.type !== 0 && channel.type !== 5) throw new Error("Discord channel cannot host a reply thread");
|
|
@@ -506,7 +499,7 @@ export function createDiscordAdapter(
|
|
|
506
499
|
);
|
|
507
500
|
if (!isRecord(thread) || typeof thread.id !== "string" || !thread.id)
|
|
508
501
|
throw new Error("Discord thread creation returned no ID");
|
|
509
|
-
rememberThread(thread.id, message.channelId
|
|
502
|
+
rememberThread(thread.id, message.channelId);
|
|
510
503
|
return { ...message, threadId: thread.id };
|
|
511
504
|
},
|
|
512
505
|
async status(message, state, signal) {
|
|
@@ -537,26 +530,22 @@ export function createDiscordAdapter(
|
|
|
537
530
|
const key = JSON.stringify([channelId, message.messageId]);
|
|
538
531
|
const messages = `${DISCORD_API}/channels/${encodeURIComponent(channelId)}/messages`;
|
|
539
532
|
const statusId = activityMessages.get(key);
|
|
540
|
-
const previous = activityHistory.get(key) ?? [];
|
|
541
533
|
if (text === undefined) {
|
|
542
534
|
if (!statusId) return;
|
|
543
535
|
const label = outcome === "success" ? "✅ Complete" : outcome === "failure" ? "❌ Failed" : "⏹ Cancelled";
|
|
544
536
|
try {
|
|
545
537
|
await messengerFetchJson(
|
|
546
538
|
`${messages}/${encodeURIComponent(statusId)}`,
|
|
547
|
-
{ method: "PATCH", headers: headers(), body: JSON.stringify({ content:
|
|
539
|
+
{ method: "PATCH", headers: headers(), body: JSON.stringify({ content: label, allowed_mentions: { parse: [] } }) },
|
|
548
540
|
signal, deps.fetch,
|
|
549
541
|
);
|
|
550
542
|
} catch (error) {
|
|
551
543
|
if (!(error instanceof MessengerHttpError && error.status === 404)) throw error;
|
|
552
544
|
}
|
|
553
545
|
activityMessages.delete(key);
|
|
554
|
-
activityHistory.delete(key);
|
|
555
546
|
return;
|
|
556
547
|
}
|
|
557
|
-
|
|
558
|
-
const history = [...previous, `• ${text}`].slice(-6);
|
|
559
|
-
const content = ["⏳ In progress", ...history].join("\n");
|
|
548
|
+
const content = `⏳ ${text.replace(/[\r\n]+/g, " ")}`;
|
|
560
549
|
if (statusId) {
|
|
561
550
|
try {
|
|
562
551
|
await messengerFetchJson(
|
|
@@ -564,7 +553,6 @@ export function createDiscordAdapter(
|
|
|
564
553
|
{ method: "PATCH", headers: headers(), body: JSON.stringify({ content, allowed_mentions: { parse: [] } }) },
|
|
565
554
|
signal, deps.fetch,
|
|
566
555
|
);
|
|
567
|
-
activityHistory.set(key, history);
|
|
568
556
|
return;
|
|
569
557
|
} catch (error) {
|
|
570
558
|
if (!(error instanceof MessengerHttpError && error.status === 404)) throw error;
|
|
@@ -579,7 +567,6 @@ export function createDiscordAdapter(
|
|
|
579
567
|
if (!isRecord(created) || typeof created.id !== "string" || !created.id)
|
|
580
568
|
throw new Error("Discord activity message returned no ID");
|
|
581
569
|
activityMessages.set(key, created.id);
|
|
582
|
-
activityHistory.set(key, history);
|
|
583
570
|
},
|
|
584
571
|
async send(message, text, signal) {
|
|
585
572
|
let files: { name: string; data: Uint8Array }[] = [];
|
package/src/messenger/gateway.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { MessengerHttpError } from "./http";
|
|
2
|
+
import { DiscordModelSelectionError } from "./model-selection";
|
|
2
3
|
import type {
|
|
3
4
|
MessengerActivityReporter,
|
|
4
5
|
MessengerAdapter,
|
|
@@ -173,7 +174,7 @@ export type MessengerSessionIdentity = readonly [
|
|
|
173
174
|
channel: string,
|
|
174
175
|
thread: OptionalIdentity,
|
|
175
176
|
conversation: OptionalIdentity,
|
|
176
|
-
user:
|
|
177
|
+
user: OptionalIdentity,
|
|
177
178
|
];
|
|
178
179
|
|
|
179
180
|
function optionalIdentity(name: string, value: string | undefined): OptionalIdentity {
|
|
@@ -201,7 +202,7 @@ export function messengerSessionIdentity(message: MessengerMessage): MessengerSe
|
|
|
201
202
|
message.parentChannelId ?? message.channelId,
|
|
202
203
|
optionalIdentity("thread", message.threadId),
|
|
203
204
|
optionalIdentity("conversation", message.conversationId),
|
|
204
|
-
message.senderId,
|
|
205
|
+
message.platform === "discord" && message.threadId !== undefined ? ["absent"] : ["present", message.senderId],
|
|
205
206
|
];
|
|
206
207
|
}
|
|
207
208
|
|
|
@@ -217,7 +218,7 @@ export function messengerSessionRoute(cwd: string, message: MessengerMessage): s
|
|
|
217
218
|
|
|
218
219
|
function schedulingKey(message: MessengerMessage, adapter: MessengerAdapter): string {
|
|
219
220
|
if (adapter.prepare && !message.direct && !message.threadId && !message.parentChannelId) {
|
|
220
|
-
return JSON.stringify([message.platform, message.accountId, message.channelId, message.
|
|
221
|
+
return JSON.stringify([message.platform, message.accountId, message.guildId, message.channelId, message.platform === "discord" ? null : message.senderId]);
|
|
221
222
|
}
|
|
222
223
|
return messengerConversationKey(message);
|
|
223
224
|
}
|
|
@@ -263,6 +264,8 @@ export interface MessengerGatewayOptions {
|
|
|
263
264
|
* order; above 1 the reply implementation must accept concurrent turns for different conversations.
|
|
264
265
|
*/
|
|
265
266
|
concurrency?: number;
|
|
267
|
+
/** Authenticated human controls bypass model queues, including busy sessions. */
|
|
268
|
+
sandboxControl?: (message: MessengerMessage, signal: AbortSignal) => Promise<{ handled: boolean; output: string }>;
|
|
266
269
|
}
|
|
267
270
|
|
|
268
271
|
export function createMessengerGateway(
|
|
@@ -285,6 +288,7 @@ export function createMessengerGateway(
|
|
|
285
288
|
const turns = new Set<Promise<void>>();
|
|
286
289
|
/** Settles when the latest request of a prepared conversation has delivered its reply. */
|
|
287
290
|
const conversationTails = new Map<string, Promise<void>>();
|
|
291
|
+
const admissionTails = new Map<string, Promise<void>>();
|
|
288
292
|
const lifetime = new AbortController();
|
|
289
293
|
let running = false;
|
|
290
294
|
let fatalError: MessengerFatalError | undefined;
|
|
@@ -353,7 +357,9 @@ export function createMessengerGateway(
|
|
|
353
357
|
return;
|
|
354
358
|
}
|
|
355
359
|
if (signal.aborted) return;
|
|
356
|
-
text =
|
|
360
|
+
text = error instanceof DiscordModelSelectionError
|
|
361
|
+
? "Discord could not verify a model. Ask the operator to configure an explicit Discord model, check provider credentials and live model discovery, then restart the messenger service."
|
|
362
|
+
: "The request could not be completed. Please try again.";
|
|
357
363
|
}
|
|
358
364
|
if (!signal.aborted && text.trim()) {
|
|
359
365
|
// A failed send may already have delivered a chunk: never retry the entire reply.
|
|
@@ -390,21 +396,38 @@ export function createMessengerGateway(
|
|
|
390
396
|
}
|
|
391
397
|
}
|
|
392
398
|
|
|
393
|
-
function handle(message: MessengerMessage, signal?: AbortSignal): Promise<void> {
|
|
399
|
+
async function handle(message: MessengerMessage, signal?: AbortSignal): Promise<void> {
|
|
394
400
|
const adapter = byPlatform.get(message.platform);
|
|
395
401
|
const effectiveSignal = signal ? AbortSignal.any([lifetime.signal, signal]) : lifetime.signal;
|
|
396
402
|
if (effectiveSignal.aborted || !adapter || !isMessengerMessageAllowed(message, config)) return Promise.resolve();
|
|
397
403
|
const id = JSON.stringify([message.platform, message.accountId, message.channelId, message.messageId]);
|
|
398
|
-
if (seen.has(id)
|
|
404
|
+
if (seen.has(id)) return;
|
|
399
405
|
seen.add(id);
|
|
400
406
|
if (seen.size > MAX_SEEN_MESSAGES) {
|
|
401
407
|
const oldest = seen.values().next().value;
|
|
402
408
|
if (oldest !== undefined) seen.delete(oldest);
|
|
403
409
|
}
|
|
404
|
-
const
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
410
|
+
const key = schedulingKey(message, adapter);
|
|
411
|
+
const previous = admissionTails.get(key) ?? Promise.resolve();
|
|
412
|
+
const ready = Promise.withResolvers<void>();
|
|
413
|
+
const tail = previous.then(() => ready.promise);
|
|
414
|
+
admissionTails.set(key, tail);
|
|
415
|
+
void tail.then(() => { if (admissionTails.get(key) === tail) admissionTails.delete(key); });
|
|
416
|
+
try {
|
|
417
|
+
if (options.sandboxControl) {
|
|
418
|
+
const control = await options.sandboxControl(message, effectiveSignal);
|
|
419
|
+
if (control.handled) {
|
|
420
|
+
if (!effectiveSignal.aborted && control.output.trim()) await adapter.send(message, control.output, effectiveSignal);
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
await previous;
|
|
425
|
+
if (effectiveSignal.aborted || queue.length >= MAX_QUEUED_MESSAGES) return;
|
|
426
|
+
const { promise, resolve } = Promise.withResolvers<void>();
|
|
427
|
+
queue.push({ message, adapter, signal: effectiveSignal, resolve, key });
|
|
428
|
+
schedule();
|
|
429
|
+
return promise;
|
|
430
|
+
} finally { ready.resolve(); }
|
|
408
431
|
}
|
|
409
432
|
|
|
410
433
|
return {
|