jeo-code 0.12.13 → 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 +42 -0
- package/README.ja.md +32 -8
- package/README.ko.md +32 -8
- package/README.md +32 -8
- package/README.zh.md +32 -8
- 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 +37 -43
- 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,14 +335,15 @@ 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
348
|
const headers = (): Record<string, string> => {
|
|
348
349
|
if (!token) throw new DiscordConfigurationError("Discord bot token is missing");
|
|
@@ -386,57 +387,50 @@ export function createDiscordAdapter(
|
|
|
386
387
|
initialUrl = discordGatewayUrl(gateway.url);
|
|
387
388
|
}
|
|
388
389
|
const deliver = async (message: MessengerMessage): Promise<void> => {
|
|
389
|
-
const
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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)) {
|
|
403
405
|
const channel = await messengerFetchJson(
|
|
404
406
|
`${DISCORD_API}/channels/${encodeURIComponent(message.channelId)}`,
|
|
405
|
-
{ headers: headers() },
|
|
406
|
-
signal,
|
|
407
|
-
deps.fetch,
|
|
407
|
+
{ headers: headers() }, signal, deps.fetch,
|
|
408
408
|
);
|
|
409
|
-
if (
|
|
410
|
-
|
|
411
|
-
channel.
|
|
412
|
-
typeof channel.type === "number" &&
|
|
413
|
-
[10, 11, 12].includes(channel.type) &&
|
|
414
|
-
typeof channel.parent_id === "string" &&
|
|
415
|
-
channel.parent_id
|
|
416
|
-
) {
|
|
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;
|
|
417
412
|
if (!message.mentioned) {
|
|
418
413
|
const originalId = typeof channel.name === "string" ? /^Request (\d{1,20})$/.exec(channel.name)?.[1] : undefined;
|
|
419
414
|
if (channel.owner_id !== accountId || !originalId ||
|
|
420
|
-
(!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;
|
|
421
416
|
const original = await messengerFetchJson(
|
|
422
417
|
`${DISCORD_API}/channels/${encodeURIComponent(channel.parent_id)}/messages/${originalId}`,
|
|
423
418
|
{ headers: headers() }, signal, deps.fetch,
|
|
424
419
|
);
|
|
425
420
|
if (!isRecord(original) || original.id !== originalId || original.channel_id !== channel.parent_id ||
|
|
426
|
-
!isRecord(original.author) || original.author.id !==
|
|
427
|
-
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;
|
|
428
422
|
}
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
});
|
|
423
|
+
rememberThread(message.channelId, channel.parent_id);
|
|
424
|
+
await forward({ ...message, threadId: message.channelId, parentChannelId: channel.parent_id });
|
|
425
|
+
} else {
|
|
426
|
+
await forward(message);
|
|
434
427
|
}
|
|
435
|
-
}
|
|
436
|
-
|
|
428
|
+
} else {
|
|
429
|
+
await forward(message);
|
|
437
430
|
}
|
|
438
|
-
}
|
|
439
|
-
|
|
431
|
+
} finally {
|
|
432
|
+
ready.resolve();
|
|
433
|
+
if (normalizationTails.get(message.channelId) === tail) normalizationTails.delete(message.channelId);
|
|
440
434
|
}
|
|
441
435
|
};
|
|
442
436
|
await connectGateway(
|
|
@@ -479,7 +473,7 @@ export function createDiscordAdapter(
|
|
|
479
473
|
async prepare(message, signal) {
|
|
480
474
|
if (message.direct) return message;
|
|
481
475
|
if (message.threadId) {
|
|
482
|
-
if (message.parentChannelId) rememberThread(message.threadId, message.parentChannelId
|
|
476
|
+
if (message.parentChannelId) rememberThread(message.threadId, message.parentChannelId);
|
|
483
477
|
return message;
|
|
484
478
|
}
|
|
485
479
|
const channel = await messengerFetchJson(
|
|
@@ -493,7 +487,7 @@ export function createDiscordAdapter(
|
|
|
493
487
|
if ([10, 11, 12].includes(channel.type)) {
|
|
494
488
|
if (typeof channel.parent_id !== "string" || !channel.parent_id)
|
|
495
489
|
throw new Error("Discord thread parent unavailable");
|
|
496
|
-
rememberThread(message.channelId, channel.parent_id
|
|
490
|
+
rememberThread(message.channelId, channel.parent_id);
|
|
497
491
|
return { ...message, threadId: message.channelId, parentChannelId: channel.parent_id };
|
|
498
492
|
}
|
|
499
493
|
if (channel.type !== 0 && channel.type !== 5) throw new Error("Discord channel cannot host a reply thread");
|
|
@@ -505,7 +499,7 @@ export function createDiscordAdapter(
|
|
|
505
499
|
);
|
|
506
500
|
if (!isRecord(thread) || typeof thread.id !== "string" || !thread.id)
|
|
507
501
|
throw new Error("Discord thread creation returned no ID");
|
|
508
|
-
rememberThread(thread.id, message.channelId
|
|
502
|
+
rememberThread(thread.id, message.channelId);
|
|
509
503
|
return { ...message, threadId: thread.id };
|
|
510
504
|
},
|
|
511
505
|
async status(message, state, signal) {
|
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 {
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { readGlobalConfig, type Config } from "../agent/state";
|
|
2
|
+
import { listProviderModels } from "../ai/model-discovery";
|
|
3
|
+
import { credentialForCall, effectiveCredentialForProvider, qualifyModelId, resolveProvider } from "../ai/model-manager";
|
|
4
|
+
import { resolveModelId } from "../ai/model-registry";
|
|
5
|
+
import { resolveCredential, type AuthProvider, type Credential } from "../auth";
|
|
6
|
+
|
|
7
|
+
export class DiscordModelSelectionError extends Error {
|
|
8
|
+
constructor(cause: unknown) {
|
|
9
|
+
super(cause instanceof Error ? cause.message : "Discord model selection failed", { cause });
|
|
10
|
+
this.name = "DiscordModelSelectionError";
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Select only from the account's live catalog; never substitute a static model. */
|
|
15
|
+
export async function selectDiscordModel(
|
|
16
|
+
explicit: string | undefined,
|
|
17
|
+
opts: { config?: Config; signal?: AbortSignal; list?: typeof listProviderModels; credential?: typeof resolveCredential } = {},
|
|
18
|
+
): Promise<string> {
|
|
19
|
+
try {
|
|
20
|
+
return await selectDiscordModelFromCatalog(explicit, opts);
|
|
21
|
+
} catch (error) {
|
|
22
|
+
if (opts.signal?.aborted) throw error;
|
|
23
|
+
throw new DiscordModelSelectionError(error);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function selectDiscordModelFromCatalog(
|
|
28
|
+
explicit: string | undefined,
|
|
29
|
+
opts: { config?: Config; signal?: AbortSignal; list?: typeof listProviderModels; credential?: typeof resolveCredential },
|
|
30
|
+
): Promise<string> {
|
|
31
|
+
const config = opts.config ?? await readGlobalConfig();
|
|
32
|
+
const requested = explicit?.trim() ? await resolveModelId(explicit.trim(), config) : undefined;
|
|
33
|
+
const provider = resolveProvider(requested || await resolveModelId(config.defaultModel, config));
|
|
34
|
+
if (!requested && provider !== "anthropic" && provider !== "openai" && provider !== "antigravity") {
|
|
35
|
+
throw new Error(`Discord automatic model selection does not support provider ${provider}; configure an explicit model`);
|
|
36
|
+
}
|
|
37
|
+
const result = await (opts.list ?? listProviderModels)(provider, { config, signal: opts.signal, limit: Number.POSITIVE_INFINITY, preferOAuth: true });
|
|
38
|
+
if (opts.signal?.aborted) throw new Error("Messenger request aborted");
|
|
39
|
+
if (!result.ok || result.fallback || result.source === "none" || (result.source === "keyless" && (!requested || (provider !== "ollama" && provider !== "lmstudio"))) || !result.models.length) {
|
|
40
|
+
throw new Error(`Discord cannot verify available models for ${provider}; check login and live model discovery`);
|
|
41
|
+
}
|
|
42
|
+
const canonical = (model: string) => qualifyModelId(model, provider);
|
|
43
|
+
const verified = async (model: string): Promise<string> => {
|
|
44
|
+
const authProvider: AuthProvider | undefined = provider === "anthropic" ? "anthropic" : provider === "openai" ? "openai" : undefined;
|
|
45
|
+
if (authProvider && config.providers[authProvider]) {
|
|
46
|
+
const credential: Credential = await (opts.credential ?? resolveCredential)(authProvider);
|
|
47
|
+
const effective = effectiveCredentialForProvider(authProvider, credential, config, model);
|
|
48
|
+
const actual = credentialForCall(provider, effective, config, provider === "openai" ? config.openaiBaseUrl : undefined);
|
|
49
|
+
if (actual.kind !== result.source) {
|
|
50
|
+
throw new Error(`Discord cannot verify ${provider} model access for the credential used at runtime`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return canonical(model);
|
|
54
|
+
};
|
|
55
|
+
if (requested) {
|
|
56
|
+
const prefix = `${provider}/`;
|
|
57
|
+
const requestedId = requested.startsWith(prefix) ? requested.slice(prefix.length) : requested;
|
|
58
|
+
const match = result.models.find(model => (model.startsWith(prefix) ? model.slice(prefix.length) : model) === requestedId);
|
|
59
|
+
if (!match) throw new Error(`Discord configured model is not available for ${provider}: ${requested}`);
|
|
60
|
+
return verified(match);
|
|
61
|
+
}
|
|
62
|
+
const family = provider === "anthropic" ? /sonnet/i : provider === "openai" ? /terra/i : /gemini.*flash/i;
|
|
63
|
+
const variant = /(?:^|[^a-z0-9])(?:mini|lite|codex|thinking|image)(?=$|[^a-z0-9])/i;
|
|
64
|
+
const models = [...new Set(result.models.filter(model => {
|
|
65
|
+
const name = provider === "antigravity" ? (result.displayNames?.[model] ?? model) : model;
|
|
66
|
+
return family.test(name) && !variant.test(model) && !variant.test(name);
|
|
67
|
+
}))];
|
|
68
|
+
if (!models.length) throw new Error(`Discord found no available ${provider} model in the required family`);
|
|
69
|
+
if (models.length === 1) return verified(models[0]!);
|
|
70
|
+
// Antigravity wire aliases can advertise a different version than their IDs.
|
|
71
|
+
// Only the display name from the same live response establishes recency.
|
|
72
|
+
if (provider === "antigravity" && models.some(model => !result.displayNames?.[model])) {
|
|
73
|
+
throw new Error("Discord cannot determine the newest Antigravity Flash model: live catalog is missing display names; configure an explicit model from the live catalog");
|
|
74
|
+
}
|
|
75
|
+
const ranked = models.map(model => {
|
|
76
|
+
const name = provider === "antigravity" ? result.displayNames![model]! : model;
|
|
77
|
+
const snapshot = name.match(/[-_ ](\d{4}-\d{2}-\d{2}|\d{8})$/);
|
|
78
|
+
const versionName = snapshot ? name.slice(0, -snapshot[0].length) : name;
|
|
79
|
+
const tierName = provider === "antigravity" ? name.match(/\((high|medium|low)\)\s*$/i)?.[1]?.toLowerCase() : undefined;
|
|
80
|
+
const tier = tierName === "high" ? 3 : tierName === "medium" ? 2 : tierName === "low" ? 1 : 0;
|
|
81
|
+
return { model, version: (versionName.match(/\d+/g) ?? []).map(Number), snapshot: Number(snapshot?.[1]?.replaceAll("-", "") ?? 0), tier, ambiguousVersion: provider === "openai" && /terra.*\d/i.test(versionName) };
|
|
82
|
+
});
|
|
83
|
+
if (ranked.some(item => !item.version.length || item.ambiguousVersion)) {
|
|
84
|
+
throw new Error(`Discord cannot determine the newest ${provider} model; configure an explicit model`);
|
|
85
|
+
}
|
|
86
|
+
const compare = (a: number[], b: number[]) => {
|
|
87
|
+
for (let i = 0; i < Math.max(a.length, b.length); i++) {
|
|
88
|
+
const difference = (b[i] ?? 0) - (a[i] ?? 0);
|
|
89
|
+
if (difference) return difference;
|
|
90
|
+
}
|
|
91
|
+
return 0;
|
|
92
|
+
};
|
|
93
|
+
ranked.sort((a, b) => compare(a.version, b.version) || b.snapshot - a.snapshot || b.tier - a.tier);
|
|
94
|
+
if (provider === "antigravity" && ranked.some((item, index) => index > 0 && compare(ranked[0]!.version, item.version) === 0 && ranked[0]!.snapshot === item.snapshot && (!ranked[0]!.tier || !item.tier))) {
|
|
95
|
+
throw new Error("Discord cannot distinguish the newest antigravity models; configure an explicit model");
|
|
96
|
+
}
|
|
97
|
+
if (compare(ranked[0]!.version, ranked[1]!.version) === 0 && ranked[0]!.snapshot === ranked[1]!.snapshot && ranked[0]!.tier === ranked[1]!.tier) {
|
|
98
|
+
throw new Error(`Discord cannot distinguish the newest ${provider} models; configure an explicit model`);
|
|
99
|
+
}
|
|
100
|
+
return verified(ranked[0]!.model);
|
|
101
|
+
}
|