@songsid/agend 2.1.8-beta.2 → 2.1.8-beta.4
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/dist/agent-cli.js +14 -0
- package/dist/agent-cli.js.map +1 -1
- package/dist/agent-endpoint.d.ts +4 -1
- package/dist/agent-endpoint.js +10 -1
- package/dist/agent-endpoint.js.map +1 -1
- package/dist/backend/cli-env-probe-worker.d.ts +1 -0
- package/dist/backend/cli-env-probe-worker.js +25 -0
- package/dist/backend/cli-env-probe-worker.js.map +1 -0
- package/dist/backend/muse.js +30 -2
- package/dist/backend/muse.js.map +1 -1
- package/dist/backend/types.d.ts +12 -1
- package/dist/backend/types.js +14 -3
- package/dist/backend/types.js.map +1 -1
- package/dist/channel/adapters/discord.d.ts +16 -4
- package/dist/channel/adapters/discord.js +20 -5
- package/dist/channel/adapters/discord.js.map +1 -1
- package/dist/channel/mcp-tools.js +6 -0
- package/dist/channel/mcp-tools.js.map +1 -1
- package/dist/daemon.d.ts +2 -0
- package/dist/daemon.js +24 -18
- package/dist/daemon.js.map +1 -1
- package/dist/fleet-context.d.ts +5 -0
- package/dist/fleet-context.js.map +1 -1
- package/dist/fleet-manager.d.ts +66 -11
- package/dist/fleet-manager.js +430 -61
- package/dist/fleet-manager.js.map +1 -1
- package/dist/general-knowledge/skills/persona-emoji/SKILL.md +52 -0
- package/dist/locale.js +18 -0
- package/dist/locale.js.map +1 -1
- package/dist/outbound-schemas.d.ts +21 -4
- package/dist/outbound-schemas.js +12 -0
- package/dist/outbound-schemas.js.map +1 -1
- package/dist/settings-api.d.ts +6 -8
- package/dist/settings-api.js +9 -4
- package/dist/settings-api.js.map +1 -1
- package/dist/status-emojis.d.ts +19 -0
- package/dist/status-emojis.js.map +1 -1
- package/dist/tool-permissions.js +8 -2
- package/dist/tool-permissions.js.map +1 -1
- package/dist/topic-commands.d.ts +9 -0
- package/dist/topic-commands.js +41 -2
- package/dist/topic-commands.js.map +1 -1
- package/dist/ui/settings.html +23 -11
- package/package.json +1 -1
package/dist/fleet-manager.js
CHANGED
|
@@ -3,6 +3,7 @@ import { randomBytes, randomUUID } from "node:crypto";
|
|
|
3
3
|
import { spawnSync } from "node:child_process";
|
|
4
4
|
import { freemem, totalmem, cpus } from "node:os";
|
|
5
5
|
import { createServer } from "node:http";
|
|
6
|
+
import { Worker } from "node:worker_threads";
|
|
6
7
|
import { join, dirname, basename } from "node:path";
|
|
7
8
|
import { fileURLToPath } from "node:url";
|
|
8
9
|
import { getAgendHome, ensureWorkspaceGit } from "./paths.js";
|
|
@@ -16,7 +17,7 @@ const __filename = fileURLToPath(import.meta.url);
|
|
|
16
17
|
const __dirname = dirname(__filename);
|
|
17
18
|
/** Fallback access policy for a channel with no `access:` block — open (no gate). */
|
|
18
19
|
const DEFAULT_OPEN_ACCESS = { mode: "open", allowed_users: [], max_pending_codes: 0, code_expiry_minutes: 0 };
|
|
19
|
-
import { STATUS_EMOJI_KEYS, builtinStatusEmojis, reactionForm, reactionMatchKey, resolveStatusEmojis, statusAvoidList, statusMatchKey, statusMatchKeys, textForm, } from "./status-emojis.js";
|
|
20
|
+
import { STATUS_EMOJI_CONFIG_KEYS, STATUS_EMOJI_KEYS, STATUS_EMOJI_SUGGESTIONS, TELEGRAM_REACTION_EMOJIS, builtinStatusEmojis, customEmojiValue, emojiImageUrl, normalizeEmoji, previewStatusEmojis, reactionForm, reactionMatchKey, resolveStatusEmojis, statusAvoidList, statusEmojiProblem, statusMatchKey, statusMatchKeys, textForm, } from "./status-emojis.js";
|
|
20
21
|
import { isProbeableRouteTarget } from "./fleet-context.js";
|
|
21
22
|
import { loadFleetConfig, loadRawFleetConfig, DEFAULT_COST_GUARD, DEFAULT_DAILY_SUMMARY, DEFAULT_INSTANCE_CONFIG } from "./config.js";
|
|
22
23
|
import { EventLog } from "./event-log.js";
|
|
@@ -28,7 +29,7 @@ import { IpcClient } from "./channel/ipc-bridge.js";
|
|
|
28
29
|
import { createAdapter } from "./channel/factory.js";
|
|
29
30
|
import { TelegramAdapter } from "./channel/adapters/telegram.js";
|
|
30
31
|
import { createBackend } from "./backend/factory.js";
|
|
31
|
-
import { isModelCompatible } from "./backend/types.js";
|
|
32
|
+
import { isModelCompatible, SYSINFO_BACKEND_IDS } from "./backend/types.js";
|
|
32
33
|
import { createLogger, rotateLogIfNeeded } from "./logger.js";
|
|
33
34
|
import { processAttachments } from "./channel/attachment-handler.js";
|
|
34
35
|
import { routeToolCall } from "./channel/tool-router.js";
|
|
@@ -3915,7 +3916,7 @@ export class FleetManager {
|
|
|
3915
3916
|
}
|
|
3916
3917
|
else if (data.command === "sysinfo") {
|
|
3917
3918
|
// Slash commands are Discord-only; use plain lines (no markdown table)
|
|
3918
|
-
await
|
|
3919
|
+
await this.topicCommands.sendSysInfo(text => data.respond(text), { platform: "discord" });
|
|
3919
3920
|
}
|
|
3920
3921
|
else if (data.command === "dashboard") {
|
|
3921
3922
|
// Reply is ephemeral (adapter defers non-chat commands ephemerally), so
|
|
@@ -4275,7 +4276,7 @@ export class FleetManager {
|
|
|
4275
4276
|
}
|
|
4276
4277
|
else if (data.command === "sysinfo") {
|
|
4277
4278
|
// Slash commands are Discord-only; use plain lines (no markdown table)
|
|
4278
|
-
await
|
|
4279
|
+
await this.topicCommands.sendSysInfo(text => data.respond(text), { platform: "discord" });
|
|
4279
4280
|
}
|
|
4280
4281
|
else if (data.command === "dashboard") {
|
|
4281
4282
|
// Reply is ephemeral (adapter defers non-chat commands ephemerally), so
|
|
@@ -5850,7 +5851,8 @@ export class FleetManager {
|
|
|
5850
5851
|
: type.startsWith("fleet_decision_") ? "fleet_decision_response"
|
|
5851
5852
|
: type === "fleet_task" ? "fleet_task_response"
|
|
5852
5853
|
: type === "fleet_set_display_name" ? "fleet_display_name_response"
|
|
5853
|
-
: "
|
|
5854
|
+
: type === "fleet_list_emojis" || type === "fleet_set_persona_emoji" || type === "fleet_preview_emojis" ? "fleet_persona_emoji_response"
|
|
5855
|
+
: "fleet_description_response";
|
|
5854
5856
|
ipc.send({ type: responseType, fleetRequestId, error: message });
|
|
5855
5857
|
}
|
|
5856
5858
|
dispatchTypedIpc(name, msg) {
|
|
@@ -5875,6 +5877,24 @@ export class FleetManager {
|
|
|
5875
5877
|
this.handleSetDescription(name, msg);
|
|
5876
5878
|
return;
|
|
5877
5879
|
}
|
|
5880
|
+
if (type === "fleet_list_emojis" || type === "fleet_set_persona_emoji" || type === "fleet_preview_emojis") {
|
|
5881
|
+
this.handlePersonaEmoji(name, msg);
|
|
5882
|
+
return;
|
|
5883
|
+
}
|
|
5884
|
+
}
|
|
5885
|
+
/** `list_emojis` / `set_persona_emoji` / `preview_emojis` over IPC; the agent endpoint calls the same methods. */
|
|
5886
|
+
handlePersonaEmoji(instanceName, msg) {
|
|
5887
|
+
const fleetRequestId = msg.fleetRequestId;
|
|
5888
|
+
const payload = (msg.payload ?? {});
|
|
5889
|
+
const ipc = this.instanceIpcClients.get(instanceName);
|
|
5890
|
+
if (!ipc)
|
|
5891
|
+
return;
|
|
5892
|
+
const op = msg.type === "fleet_list_emojis" ? this.listEmojisFor(instanceName, payload.refresh === true)
|
|
5893
|
+
: msg.type === "fleet_preview_emojis" ? this.previewEmojis(instanceName, payload)
|
|
5894
|
+
: this.setPersonaEmoji(instanceName, payload);
|
|
5895
|
+
op.then((r) => typeof r.error === "string"
|
|
5896
|
+
? ipc.send({ type: "fleet_persona_emoji_response", fleetRequestId, error: r.error })
|
|
5897
|
+
: ipc.send({ type: "fleet_persona_emoji_response", fleetRequestId, result: r }), (err) => ipc.send({ type: "fleet_persona_emoji_response", fleetRequestId, error: err.message }));
|
|
5878
5898
|
}
|
|
5879
5899
|
handleScheduleCrud(instanceName, msg) {
|
|
5880
5900
|
const fleetRequestId = msg.fleetRequestId;
|
|
@@ -6217,6 +6237,15 @@ export class FleetManager {
|
|
|
6217
6237
|
return { error: `Instance '${instance}' not found` };
|
|
6218
6238
|
return { display_name: name };
|
|
6219
6239
|
}
|
|
6240
|
+
async handleListEmojisHttp(instance, refresh) {
|
|
6241
|
+
return this.listEmojisFor(instance, refresh);
|
|
6242
|
+
}
|
|
6243
|
+
async handleSetPersonaEmojiHttp(instance, args) {
|
|
6244
|
+
return this.setPersonaEmoji(instance, args);
|
|
6245
|
+
}
|
|
6246
|
+
async handlePreviewEmojisHttp(instance, args) {
|
|
6247
|
+
return this.previewEmojis(instance, args);
|
|
6248
|
+
}
|
|
6220
6249
|
async handleSetDescriptionHttp(instance, description) {
|
|
6221
6250
|
if (!this.fleetConfig)
|
|
6222
6251
|
return { error: "Fleet config not available" };
|
|
@@ -6834,6 +6863,8 @@ export class FleetManager {
|
|
|
6834
6863
|
deliveryStatusChains = new Map();
|
|
6835
6864
|
/** #725: in-flight CLI env probes, keyed by backend name. Coalesces concurrent /model requests. */
|
|
6836
6865
|
pendingCliEnvProbes = new Map();
|
|
6866
|
+
/** Forced vendor catalog refreshes are separate flights, but sysinfo joins either flight. */
|
|
6867
|
+
pendingVendorCliEnvProbes = new Map();
|
|
6837
6868
|
/**
|
|
6838
6869
|
* The status emojis for an instance (#1005): its own `status_emojis`, then
|
|
6839
6870
|
* its channel's `options.status_emojis`, then the platform built-in. An
|
|
@@ -6864,10 +6895,15 @@ export class FleetManager {
|
|
|
6864
6895
|
}
|
|
6865
6896
|
warnedStatusEmojis = new Set();
|
|
6866
6897
|
/**
|
|
6867
|
-
* A Discord connection's server emojis for the Settings picker (#1005)
|
|
6868
|
-
*
|
|
6869
|
-
*
|
|
6870
|
-
*
|
|
6898
|
+
* A Discord connection's server emojis for the Settings picker (#1005), from
|
|
6899
|
+
* every server its bot can draw on (#1021): the primary `group_id` server,
|
|
6900
|
+
* then each other server the bot is a member of that ClassicBot's
|
|
6901
|
+
* `allowed_guilds` admits (unset = all, as for classic routing). A bot can
|
|
6902
|
+
* react with a custom emoji from any server it is in. Cached per server — a
|
|
6903
|
+
* server's emoji set changes rarely — and one fetch per server at a time;
|
|
6904
|
+
* `refresh` forces new ones. One server refusing (e.g. Missing Access) is
|
|
6905
|
+
* reported on that server and does not hide the others. Uses the running
|
|
6906
|
+
* adapter, so the bot token never leaves the server.
|
|
6871
6907
|
*/
|
|
6872
6908
|
async listGuildEmojis(channelId, refresh = false) {
|
|
6873
6909
|
const world = this.worlds.get(channelId);
|
|
@@ -6878,28 +6914,267 @@ export class FleetManager {
|
|
|
6878
6914
|
const adapter = world.adapter;
|
|
6879
6915
|
if (!adapter.listGuildEmojis)
|
|
6880
6916
|
return { ok: false, error: "this adapter cannot list server emojis" };
|
|
6881
|
-
const
|
|
6882
|
-
|
|
6883
|
-
return { ok: true, ...cached };
|
|
6884
|
-
let pending = this.guildEmojiFetches.get(channelId);
|
|
6885
|
-
if (!pending) {
|
|
6886
|
-
pending = adapter.listGuildEmojis().then(emojis => {
|
|
6887
|
-
const entry = { fetched_at: Date.now(), emojis };
|
|
6888
|
-
this.guildEmojiCache.set(channelId, entry);
|
|
6889
|
-
return entry;
|
|
6890
|
-
}).finally(() => this.guildEmojiFetches.delete(channelId));
|
|
6891
|
-
this.guildEmojiFetches.set(channelId, pending);
|
|
6892
|
-
}
|
|
6917
|
+
const listEmojis = adapter.listGuildEmojis.bind(adapter);
|
|
6918
|
+
let servers;
|
|
6893
6919
|
try {
|
|
6894
|
-
|
|
6920
|
+
servers = adapter.listMemberGuilds
|
|
6921
|
+
? (await adapter.listMemberGuilds()).filter(g => g.primary || (this.classicChannels?.isGuildAllowed(g.id) ?? false))
|
|
6922
|
+
: [{ id: "", name: "", primary: true }];
|
|
6895
6923
|
}
|
|
6896
6924
|
catch (e) {
|
|
6897
|
-
return { ok: false, error: `Discord refused the
|
|
6898
|
-
}
|
|
6925
|
+
return { ok: false, error: `Discord refused the server list: ${e.message}` };
|
|
6926
|
+
}
|
|
6927
|
+
const guilds = await Promise.all(servers.map(async (g) => {
|
|
6928
|
+
const key = `${channelId}\0${g.id}`;
|
|
6929
|
+
const cached = this.guildEmojiCache.get(key);
|
|
6930
|
+
if (!refresh && cached && Date.now() - cached.fetched_at < FleetManager.GUILD_EMOJI_TTL_MS)
|
|
6931
|
+
return { ...g, ...cached };
|
|
6932
|
+
let pending = this.guildEmojiFetches.get(key);
|
|
6933
|
+
if (!pending) {
|
|
6934
|
+
pending = (g.id ? listEmojis(g.id) : listEmojis()).then(emojis => {
|
|
6935
|
+
const entry = { fetched_at: Date.now(), emojis };
|
|
6936
|
+
this.guildEmojiCache.set(key, entry);
|
|
6937
|
+
return entry;
|
|
6938
|
+
}).finally(() => this.guildEmojiFetches.delete(key));
|
|
6939
|
+
this.guildEmojiFetches.set(key, pending);
|
|
6940
|
+
}
|
|
6941
|
+
try {
|
|
6942
|
+
return { ...g, ...(await pending) };
|
|
6943
|
+
}
|
|
6944
|
+
catch (e) {
|
|
6945
|
+
return { ...g, error: `Discord refused the emoji list: ${e.message}` };
|
|
6946
|
+
}
|
|
6947
|
+
}));
|
|
6948
|
+
const listed = guilds.filter((g) => !!g.emojis);
|
|
6949
|
+
if (!listed.length)
|
|
6950
|
+
return { ok: false, error: guilds[0]?.error ?? "no server to list emojis from" };
|
|
6951
|
+
return {
|
|
6952
|
+
ok: true,
|
|
6953
|
+
fetched_at: Math.min(...listed.map(g => g.fetched_at)),
|
|
6954
|
+
emojis: listed.flatMap(g => g.emojis),
|
|
6955
|
+
guilds,
|
|
6956
|
+
};
|
|
6899
6957
|
}
|
|
6900
6958
|
static GUILD_EMOJI_TTL_MS = 10 * 60_000;
|
|
6959
|
+
/** Keyed `<connection>\0<server id>`. */
|
|
6901
6960
|
guildEmojiCache = new Map();
|
|
6902
6961
|
guildEmojiFetches = new Map();
|
|
6962
|
+
/**
|
|
6963
|
+
* What `instanceName` may pick as its persona emoji (the `list_emojis`
|
|
6964
|
+
* tool): its current status set with where each value comes from, the
|
|
6965
|
+
* standard emojis its platform takes, and on Discord the server emojis its
|
|
6966
|
+
* bot can draw on — the same lists, and the same resolution, as the
|
|
6967
|
+
* Settings picker (#1005/#1021), so an agent and an operator see one truth.
|
|
6968
|
+
*/
|
|
6969
|
+
async listEmojisFor(instanceName, refresh = false) {
|
|
6970
|
+
const self = this.fleetConfig?.instances[instanceName];
|
|
6971
|
+
if (!self)
|
|
6972
|
+
return { error: this.personaEmojiMissing(instanceName) };
|
|
6973
|
+
const worldId = this.getInstanceAdapterId(instanceName);
|
|
6974
|
+
const { platform } = this.resolveStatusEmojisFor(instanceName);
|
|
6975
|
+
const channel = (worldId ? this.worlds.get(worldId)?.channelConfig : undefined) ?? this.getChannelConfig(worldId ?? undefined);
|
|
6976
|
+
const current = previewStatusEmojis({ platform, platformConfig: channel?.options?.status_emojis, instanceConfig: self.status_emojis });
|
|
6977
|
+
const out = {
|
|
6978
|
+
platform: platform ?? null,
|
|
6979
|
+
statuses: current.entries.map(e => ({ status: e.key, value: e.value, source: e.source })),
|
|
6980
|
+
standard: platform === "telegram"
|
|
6981
|
+
? { reactions: [...TELEGRAM_REACTION_EMOJIS], note: "Telegram reacts only with these; progress_prefix may be any single emoji" }
|
|
6982
|
+
: { suggestions: STATUS_EMOJI_SUGGESTIONS, note: "any single emoji works" },
|
|
6983
|
+
};
|
|
6984
|
+
if (platform === "discord" && worldId) {
|
|
6985
|
+
const listed = await this.listGuildEmojis(worldId, refresh);
|
|
6986
|
+
out.server_emojis = listed.ok
|
|
6987
|
+
? listed.guilds.map(g => g.emojis
|
|
6988
|
+
? {
|
|
6989
|
+
server: g.name || g.id, primary: g.primary,
|
|
6990
|
+
emojis: g.emojis.filter(e => e.available).map(e => {
|
|
6991
|
+
const value = customEmojiValue(e);
|
|
6992
|
+
return { value, image_url: emojiImageUrl(value) };
|
|
6993
|
+
}),
|
|
6994
|
+
}
|
|
6995
|
+
: { server: g.name || g.id, primary: g.primary, error: g.error })
|
|
6996
|
+
: { error: listed.error };
|
|
6997
|
+
}
|
|
6998
|
+
return out;
|
|
6999
|
+
}
|
|
7000
|
+
/**
|
|
7001
|
+
* `preview_emojis` (#1040): download a few Discord server emojis so an agent
|
|
7002
|
+
* can look at them before it picks one — a name and an id say nothing about
|
|
7003
|
+
* what an emoji looks like. The fleet fetches, not the agent (not every
|
|
7004
|
+
* backend can), and returns a local path per emoji to Read, as
|
|
7005
|
+
* download_attachment does for an attachment.
|
|
7006
|
+
*
|
|
7007
|
+
* SSRF-safe by construction: nothing from the caller reaches a URL. The
|
|
7008
|
+
* caller names emojis; each must be an available one in a server this bot
|
|
7009
|
+
* can draw on (the list set_persona_emoji checks), and the CDN URL is built
|
|
7010
|
+
* from that listed emoji's numeric id. Static PNG, size-capped, cached by id.
|
|
7011
|
+
*/
|
|
7012
|
+
async previewEmojis(instanceName, args) {
|
|
7013
|
+
const self = this.fleetConfig?.instances[instanceName];
|
|
7014
|
+
if (!self)
|
|
7015
|
+
return { error: this.personaEmojiMissing(instanceName) };
|
|
7016
|
+
const wanted = args.emojis;
|
|
7017
|
+
if (!Array.isArray(wanted) || wanted.length === 0 || wanted.some(e => typeof e !== "string")) {
|
|
7018
|
+
return { error: "emojis is required: a list of <:name:id> values from list_emojis" };
|
|
7019
|
+
}
|
|
7020
|
+
if (wanted.length > FleetManager.EMOJI_PREVIEW_MAX) {
|
|
7021
|
+
return { error: `at most ${FleetManager.EMOJI_PREVIEW_MAX} at a time: narrow them down by name first` };
|
|
7022
|
+
}
|
|
7023
|
+
const { platform } = this.resolveStatusEmojisFor(instanceName);
|
|
7024
|
+
const worldId = this.getInstanceAdapterId(instanceName);
|
|
7025
|
+
if (platform !== "discord" || !worldId) {
|
|
7026
|
+
return { error: "only Discord server emojis need a preview; standard emojis are what they look like" };
|
|
7027
|
+
}
|
|
7028
|
+
const listed = await this.listGuildEmojis(worldId);
|
|
7029
|
+
if (!listed.ok)
|
|
7030
|
+
return { error: `cannot list this bot's server emojis: ${listed.error}` };
|
|
7031
|
+
const previews = [];
|
|
7032
|
+
const errors = [];
|
|
7033
|
+
for (const raw of wanted) {
|
|
7034
|
+
const e = normalizeEmoji(raw);
|
|
7035
|
+
if (e?.kind !== "custom") {
|
|
7036
|
+
errors.push({ emoji: raw, error: "not a server emoji (a standard emoji needs no preview)" });
|
|
7037
|
+
continue;
|
|
7038
|
+
}
|
|
7039
|
+
const found = listed.emojis.find(g => g.id === e.id);
|
|
7040
|
+
if (!found?.available) {
|
|
7041
|
+
errors.push({ emoji: raw, error: "not a server emoji this bot can use" });
|
|
7042
|
+
continue;
|
|
7043
|
+
}
|
|
7044
|
+
try {
|
|
7045
|
+
previews.push({ emoji: customEmojiValue(found), path: await this.fetchEmojiPreview(found.id) });
|
|
7046
|
+
}
|
|
7047
|
+
catch (err) {
|
|
7048
|
+
errors.push({ emoji: raw, error: `download failed: ${err.message}` });
|
|
7049
|
+
}
|
|
7050
|
+
}
|
|
7051
|
+
return { previews, errors, note: "Read each path to see the emoji (a static PNG)." };
|
|
7052
|
+
}
|
|
7053
|
+
static EMOJI_PREVIEW_MAX = 8;
|
|
7054
|
+
static EMOJI_PREVIEW_MAX_BYTES = 256 * 1024;
|
|
7055
|
+
static EMOJI_PREVIEW_TTL_MS = 24 * 60 * 60_000;
|
|
7056
|
+
/** One emoji's static PNG under the inbox, reused while fresh; stale ones are pruned. */
|
|
7057
|
+
async fetchEmojiPreview(id) {
|
|
7058
|
+
if (!/^\d{15,25}$/.test(id))
|
|
7059
|
+
throw new Error("not a Discord emoji id");
|
|
7060
|
+
const dir = join(this.dataDir, "inbox", "emoji-previews");
|
|
7061
|
+
mkdirSync(dir, { recursive: true });
|
|
7062
|
+
const now = Date.now();
|
|
7063
|
+
for (const name of readdirSync(dir)) {
|
|
7064
|
+
try {
|
|
7065
|
+
if (now - statSync(join(dir, name)).mtimeMs > FleetManager.EMOJI_PREVIEW_TTL_MS)
|
|
7066
|
+
unlinkSync(join(dir, name));
|
|
7067
|
+
}
|
|
7068
|
+
catch { /* raced */ }
|
|
7069
|
+
}
|
|
7070
|
+
const path = join(dir, `${id}.png`);
|
|
7071
|
+
if (existsSync(path))
|
|
7072
|
+
return path;
|
|
7073
|
+
const response = await fetch(`https://cdn.discordapp.com/emojis/${id}.png?size=96`, { signal: AbortSignal.timeout(10_000), redirect: "error" });
|
|
7074
|
+
if (!response.ok)
|
|
7075
|
+
throw new Error(`HTTP ${response.status}`);
|
|
7076
|
+
if (!/^image\/png\b/.test(response.headers.get("content-type") ?? ""))
|
|
7077
|
+
throw new Error("not a PNG");
|
|
7078
|
+
const reader = response.body?.getReader();
|
|
7079
|
+
if (!reader)
|
|
7080
|
+
throw new Error("empty response");
|
|
7081
|
+
const chunks = [];
|
|
7082
|
+
let size = 0;
|
|
7083
|
+
for (;;) {
|
|
7084
|
+
const { done, value } = await reader.read();
|
|
7085
|
+
if (done)
|
|
7086
|
+
break;
|
|
7087
|
+
size += value.byteLength;
|
|
7088
|
+
if (size > FleetManager.EMOJI_PREVIEW_MAX_BYTES) {
|
|
7089
|
+
await reader.cancel();
|
|
7090
|
+
throw new Error("image too large");
|
|
7091
|
+
}
|
|
7092
|
+
chunks.push(value);
|
|
7093
|
+
}
|
|
7094
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
7095
|
+
writeFileSync(tmp, Buffer.concat(chunks));
|
|
7096
|
+
renameSync(tmp, path);
|
|
7097
|
+
return path;
|
|
7098
|
+
}
|
|
7099
|
+
/**
|
|
7100
|
+
* `set_persona_emoji`: `instanceName` sets one of its own status emojis —
|
|
7101
|
+
* `delivered` unless it names another — in its per-instance `status_emojis`
|
|
7102
|
+
* override (#1005 addendum 2), so its stamp is told apart from other bots'
|
|
7103
|
+
* on a shared message. The value is judged exactly as Settings and the
|
|
7104
|
+
* react path judge it (statusEmojiProblem on the instance's platform), and
|
|
7105
|
+
* a Discord server emoji must be one its bot can use: an emoji from a
|
|
7106
|
+
* server it is not in would be stored, preview fine, and fail every react.
|
|
7107
|
+
* An empty emoji removes that status's override.
|
|
7108
|
+
*/
|
|
7109
|
+
async setPersonaEmoji(instanceName, args) {
|
|
7110
|
+
const self = this.fleetConfig?.instances[instanceName];
|
|
7111
|
+
if (!self)
|
|
7112
|
+
return { error: this.personaEmojiMissing(instanceName) };
|
|
7113
|
+
const status = (args.status ?? "delivered");
|
|
7114
|
+
if (!STATUS_EMOJI_CONFIG_KEYS.includes(status)) {
|
|
7115
|
+
return { error: `status must be one of ${STATUS_EMOJI_CONFIG_KEYS.join(", ")}` };
|
|
7116
|
+
}
|
|
7117
|
+
// Only an explicit "" removes the override. The agent endpoint, typed IPC
|
|
7118
|
+
// and agent-cli do not run the MCP schema, so a call missing `emoji` must
|
|
7119
|
+
// be refused here, never read as "clear my delivered stamp".
|
|
7120
|
+
if (typeof args.emoji !== "string") {
|
|
7121
|
+
return { error: 'emoji is required: one emoji, a <:name:id> from list_emojis, or "" to remove your override' };
|
|
7122
|
+
}
|
|
7123
|
+
const raw = args.emoji.trim();
|
|
7124
|
+
// Whitespace is not an explicit "": a stray " " must not clear the stamp either.
|
|
7125
|
+
if (!raw && args.emoji !== "") {
|
|
7126
|
+
return { error: 'emoji is blank: pass one emoji, a <:name:id> from list_emojis, or exactly "" to remove your override' };
|
|
7127
|
+
}
|
|
7128
|
+
let value;
|
|
7129
|
+
if (raw) {
|
|
7130
|
+
const { platform } = this.resolveStatusEmojisFor(instanceName);
|
|
7131
|
+
const problem = statusEmojiProblem(platform, status, raw);
|
|
7132
|
+
if (problem)
|
|
7133
|
+
return { error: `${problem}. Call list_emojis for what ${platform ?? "this channel"} accepts.` };
|
|
7134
|
+
const e = normalizeEmoji(raw);
|
|
7135
|
+
if (e.kind === "custom") {
|
|
7136
|
+
const worldId = this.getInstanceAdapterId(instanceName);
|
|
7137
|
+
if (!worldId)
|
|
7138
|
+
return { error: "no connection to check that server emoji against" };
|
|
7139
|
+
const listed = await this.listGuildEmojis(worldId);
|
|
7140
|
+
if (!listed.ok)
|
|
7141
|
+
return { error: `cannot check that server emoji: ${listed.error}` };
|
|
7142
|
+
const found = listed.emojis.find(g => g.id === e.id);
|
|
7143
|
+
if (!found?.available) {
|
|
7144
|
+
return { error: `${raw} is not a server emoji this bot can use${found ? " (Discord marks it unavailable)" : ""}. Call list_emojis for the ones it can.` };
|
|
7145
|
+
}
|
|
7146
|
+
value = customEmojiValue(found);
|
|
7147
|
+
}
|
|
7148
|
+
else {
|
|
7149
|
+
value = e.value;
|
|
7150
|
+
}
|
|
7151
|
+
}
|
|
7152
|
+
// Re-read after the validation above, which may have waited on Discord:
|
|
7153
|
+
// another set_persona_emoji or a Settings save can have changed another
|
|
7154
|
+
// status, or replaced this instance's config object, meanwhile. Change
|
|
7155
|
+
// only this one key on what is there now.
|
|
7156
|
+
const current = this.fleetConfig?.instances[instanceName];
|
|
7157
|
+
if (!current)
|
|
7158
|
+
return { error: this.personaEmojiMissing(instanceName) };
|
|
7159
|
+
const map = { ...(current.status_emojis ?? {}) };
|
|
7160
|
+
if (value)
|
|
7161
|
+
map[status] = value;
|
|
7162
|
+
else
|
|
7163
|
+
delete map[status];
|
|
7164
|
+
if (Object.keys(map).length)
|
|
7165
|
+
current.status_emojis = map;
|
|
7166
|
+
else
|
|
7167
|
+
delete current.status_emojis;
|
|
7168
|
+
this.saveFleetConfig();
|
|
7169
|
+
this.logger.info({ instanceName, status, value: value ?? null }, value ? "Persona emoji set" : "Persona emoji cleared");
|
|
7170
|
+
const resolved = this.resolveStatusEmojisFor(instanceName);
|
|
7171
|
+
return { status, value: value ?? null, now: resolved[status], status_emojis: current.status_emojis ?? null };
|
|
7172
|
+
}
|
|
7173
|
+
personaEmojiMissing(instanceName) {
|
|
7174
|
+
return this.classicChannels?.getAll().some(c => c.instanceName === instanceName)
|
|
7175
|
+
? "ClassicBot instances have no per-instance status emojis; an operator sets the connection's in Settings"
|
|
7176
|
+
: `Instance '${instanceName}' not found`;
|
|
7177
|
+
}
|
|
6903
7178
|
/** What the instructions tell `instanceName` not to react with (its own status set). */
|
|
6904
7179
|
statusEmojiAvoidList(instanceName) {
|
|
6905
7180
|
return statusAvoidList(this.resolveStatusEmojisFor(instanceName));
|
|
@@ -10325,6 +10600,32 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
10325
10600
|
catch { /* missing / stale / corrupt */ }
|
|
10326
10601
|
return null;
|
|
10327
10602
|
}
|
|
10603
|
+
/** Return cached CLI versions immediately. Refresh is started after /sysinfo is sent. */
|
|
10604
|
+
getBackendCliVersionSnapshot() {
|
|
10605
|
+
const snapshot = {};
|
|
10606
|
+
for (const backend of SYSINFO_BACKEND_IDS) {
|
|
10607
|
+
const cached = this.readCliEnv(backend);
|
|
10608
|
+
const needsRefresh = this.cliEnvNeedsRefresh(cached);
|
|
10609
|
+
const version = typeof cached?.version === "string" && cached.version.trim()
|
|
10610
|
+
? cached.version.trim()
|
|
10611
|
+
: null;
|
|
10612
|
+
snapshot[backend] = {
|
|
10613
|
+
version,
|
|
10614
|
+
probing: needsRefresh && version === null,
|
|
10615
|
+
};
|
|
10616
|
+
}
|
|
10617
|
+
return snapshot;
|
|
10618
|
+
}
|
|
10619
|
+
/** Called only after the user-visible sysinfo send/respond has settled. */
|
|
10620
|
+
refreshBackendCliVersions() {
|
|
10621
|
+
for (const backend of SYSINFO_BACKEND_IDS) {
|
|
10622
|
+
if (!this.cliEnvNeedsRefresh(this.readCliEnv(backend)))
|
|
10623
|
+
continue;
|
|
10624
|
+
void this.probeBackendBounded(backend, { nonBlocking: true }).catch(err => {
|
|
10625
|
+
this.logger.debug({ err, backend }, "Background CLI version refresh failed");
|
|
10626
|
+
});
|
|
10627
|
+
}
|
|
10628
|
+
}
|
|
10328
10629
|
/** True when a cached CLI env is old enough that `/model` should re-probe. */
|
|
10329
10630
|
cliEnvNeedsRefresh(env) {
|
|
10330
10631
|
return !env || typeof env.probedAt !== "number" || Date.now() - env.probedAt >= CLI_ENV_FRESH_MS;
|
|
@@ -10335,33 +10636,97 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
10335
10636
|
* previous list, never stall the command that asked for it.
|
|
10336
10637
|
*/
|
|
10337
10638
|
async probeBackendBounded(backend, opts = {}) {
|
|
10338
|
-
// #725: single-flight per backend
|
|
10339
|
-
//
|
|
10340
|
-
|
|
10341
|
-
|
|
10342
|
-
if (
|
|
10343
|
-
|
|
10344
|
-
|
|
10345
|
-
|
|
10346
|
-
|
|
10347
|
-
const work = this.probeBackend(backend, opts);
|
|
10639
|
+
// #725: single-flight per backend. Explicit vendor refreshes remain a
|
|
10640
|
+
// separate flight from an ordinary probe, while sysinfo can join either.
|
|
10641
|
+
const regularFlight = this.pendingCliEnvProbes.get(backend);
|
|
10642
|
+
const vendorFlight = this.pendingVendorCliEnvProbes.get(backend);
|
|
10643
|
+
if (opts.refreshVendorCatalog && vendorFlight)
|
|
10644
|
+
return vendorFlight;
|
|
10645
|
+
if (!opts.refreshVendorCatalog && (vendorFlight || regularFlight))
|
|
10646
|
+
return vendorFlight ?? regularFlight;
|
|
10647
|
+
const worker = opts.nonBlocking ? this.startCliEnvProbeWorker(backend, opts) : undefined;
|
|
10648
|
+
const work = worker?.promise ?? this.probeBackend(backend, opts);
|
|
10348
10649
|
work.catch(() => { });
|
|
10349
10650
|
let timer;
|
|
10350
10651
|
const deadline = new Promise(resolve => {
|
|
10351
10652
|
timer = setTimeout(() => {
|
|
10653
|
+
worker?.terminate();
|
|
10352
10654
|
this.logger.warn({ backend, deadlineMs: CLI_ENV_PROBE_DEADLINE_MS }, "CLI env live probe exceeded its deadline — serving the cached model list");
|
|
10353
10655
|
resolve(null);
|
|
10354
10656
|
}, CLI_ENV_PROBE_DEADLINE_MS);
|
|
10355
10657
|
});
|
|
10356
10658
|
const bounded = Promise.race([work, deadline]).finally(() => {
|
|
10357
10659
|
clearTimeout(timer);
|
|
10358
|
-
|
|
10359
|
-
|
|
10660
|
+
const flights = opts.refreshVendorCatalog ? this.pendingVendorCliEnvProbes : this.pendingCliEnvProbes;
|
|
10661
|
+
if (flights.get(backend) === bounded)
|
|
10662
|
+
flights.delete(backend);
|
|
10360
10663
|
});
|
|
10361
|
-
|
|
10362
|
-
this.pendingCliEnvProbes.set(backend, bounded);
|
|
10664
|
+
(opts.refreshVendorCatalog ? this.pendingVendorCliEnvProbes : this.pendingCliEnvProbes).set(backend, bounded);
|
|
10363
10665
|
return bounded;
|
|
10364
10666
|
}
|
|
10667
|
+
startCliEnvProbeWorker(backend, opts) {
|
|
10668
|
+
const workerData = {
|
|
10669
|
+
backend,
|
|
10670
|
+
instanceDir: join(getAgendHome(), "cli-env"),
|
|
10671
|
+
config: {
|
|
10672
|
+
workingDirectory: "",
|
|
10673
|
+
instanceDir: join(getAgendHome(), "cli-env"),
|
|
10674
|
+
instanceName: `probe-${backend}`,
|
|
10675
|
+
mcpServers: {},
|
|
10676
|
+
},
|
|
10677
|
+
refreshVendorCatalog: opts.refreshVendorCatalog === true,
|
|
10678
|
+
};
|
|
10679
|
+
// `tsx src/cli.ts` needs its TS module resolver inside the separate isolate.
|
|
10680
|
+
// Published builds load the compiled worker and require no tsx dependency.
|
|
10681
|
+
const worker = import.meta.url.endsWith(".ts")
|
|
10682
|
+
? new Worker(`const { workerData } = require("node:worker_threads"); import("tsx/esm/api").then(({ tsImport }) => tsImport(workerData.entry, workerData.entry));`, { eval: true, execArgv: [], workerData: { ...workerData, entry: new URL("./backend/cli-env-probe-worker.ts", import.meta.url).href } })
|
|
10683
|
+
: new Worker(new URL("./backend/cli-env-probe-worker.js", import.meta.url), { execArgv: [], workerData });
|
|
10684
|
+
let settled = false;
|
|
10685
|
+
let resolveProbe;
|
|
10686
|
+
const promise = new Promise(resolve => { resolveProbe = resolve; });
|
|
10687
|
+
const finish = (value) => {
|
|
10688
|
+
if (settled)
|
|
10689
|
+
return;
|
|
10690
|
+
settled = true;
|
|
10691
|
+
resolveProbe(value);
|
|
10692
|
+
void worker.terminate().catch(err => {
|
|
10693
|
+
this.logger.debug({ backend, err }, "CLI env worker termination failed");
|
|
10694
|
+
});
|
|
10695
|
+
};
|
|
10696
|
+
worker.once("message", (message) => {
|
|
10697
|
+
if (settled)
|
|
10698
|
+
return;
|
|
10699
|
+
if (!message.ok) {
|
|
10700
|
+
this.logger.warn({ backend, error: message.error }, "CLI env worker failed");
|
|
10701
|
+
finish(null);
|
|
10702
|
+
}
|
|
10703
|
+
else if (!message.result) {
|
|
10704
|
+
finish(null);
|
|
10705
|
+
}
|
|
10706
|
+
else {
|
|
10707
|
+
try {
|
|
10708
|
+
finish(this.persistCliEnvProbeResult(backend, message.result));
|
|
10709
|
+
}
|
|
10710
|
+
catch (err) {
|
|
10711
|
+
this.logger.warn({ backend, err }, "CLI env cache write failed");
|
|
10712
|
+
finish(null);
|
|
10713
|
+
}
|
|
10714
|
+
}
|
|
10715
|
+
});
|
|
10716
|
+
worker.once("error", err => {
|
|
10717
|
+
if (settled)
|
|
10718
|
+
return;
|
|
10719
|
+
this.logger.warn({ backend, err }, "CLI env worker crashed");
|
|
10720
|
+
finish(null);
|
|
10721
|
+
});
|
|
10722
|
+
worker.once("exit", code => {
|
|
10723
|
+
if (settled)
|
|
10724
|
+
return;
|
|
10725
|
+
this.logger.warn({ backend, code }, "CLI env worker exited without a result");
|
|
10726
|
+
finish(null);
|
|
10727
|
+
});
|
|
10728
|
+
return { promise, terminate: () => { finish(null); } };
|
|
10729
|
+
}
|
|
10365
10730
|
/**
|
|
10366
10731
|
* Resolve the effective model for a fleet or ClassicBot instance, plus where it
|
|
10367
10732
|
* came from. Single source of truth for `/model` and `/ctx` — precedence:
|
|
@@ -10428,35 +10793,39 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
10428
10793
|
if (opts.refreshVendorCatalog && be.refreshModelCatalog)
|
|
10429
10794
|
await be.refreshModelCatalog();
|
|
10430
10795
|
const probed = await be.probeCLIEnv({ workingDirectory: "", instanceDir: join(getAgendHome(), "cli-env"), instanceName: `probe-${backend}`, mcpServers: {} });
|
|
10431
|
-
|
|
10432
|
-
// An empty result must never overwrite a catalog we already have. Some
|
|
10433
|
-
// probes hit the network (`agy models` fetches, 5s cap), so a slow moment
|
|
10434
|
-
// returns [] — and writing that would blank the list for the whole 24h
|
|
10435
|
-
// TTL, long after the CLI recovered. Observed live: a good 11-model
|
|
10436
|
-
// antigravity cache replaced by an empty one. Keep the known models and
|
|
10437
|
-
// let the fresher currentModel/version through.
|
|
10438
|
-
if (!env.models?.length) {
|
|
10439
|
-
const previous = this.readCliEnv(backend);
|
|
10440
|
-
if (previous?.models?.length)
|
|
10441
|
-
env.models = previous.models;
|
|
10442
|
-
}
|
|
10443
|
-
// Same protection for the extended catalog: one offline moment must not
|
|
10444
|
-
// blank a good account list for the whole cache TTL.
|
|
10445
|
-
if (!env.apiModels?.length) {
|
|
10446
|
-
const previous = this.readCliEnv(backend);
|
|
10447
|
-
if (previous?.apiModels?.length)
|
|
10448
|
-
env.apiModels = previous.apiModels;
|
|
10449
|
-
}
|
|
10450
|
-
const path = this.cliEnvPath(backend);
|
|
10451
|
-
mkdirSync(dirname(path), { recursive: true });
|
|
10452
|
-
writeFileSync(path, JSON.stringify(env, null, 2));
|
|
10453
|
-
return env;
|
|
10796
|
+
return this.persistCliEnvProbeResult(backend, probed);
|
|
10454
10797
|
}
|
|
10455
10798
|
catch (err) {
|
|
10456
10799
|
this.logger.warn({ err, backend }, "CLI env probe failed");
|
|
10457
10800
|
return null;
|
|
10458
10801
|
}
|
|
10459
10802
|
}
|
|
10803
|
+
/** Preserve the existing cache merge rules for both in-process and worker probes. */
|
|
10804
|
+
persistCliEnvProbeResult(backend, probed) {
|
|
10805
|
+
const env = { backend, probedAt: Date.now(), ...probed };
|
|
10806
|
+
// An empty result must never overwrite a catalog we already have. Some
|
|
10807
|
+
// probes hit the network (`agy models` fetches, 5s cap), so a slow moment
|
|
10808
|
+
// returns [] — and writing that would blank the list for the whole 24h
|
|
10809
|
+
// TTL, long after the CLI recovered. Observed live: a good 11-model
|
|
10810
|
+
// antigravity cache replaced by an empty one. Keep the known models and
|
|
10811
|
+
// let the fresher currentModel/version through.
|
|
10812
|
+
if (!env.models?.length) {
|
|
10813
|
+
const previous = this.readCliEnv(backend);
|
|
10814
|
+
if (previous?.models?.length)
|
|
10815
|
+
env.models = previous.models;
|
|
10816
|
+
}
|
|
10817
|
+
// Same protection for the extended catalog: one offline moment must not
|
|
10818
|
+
// blank a good account list for the whole cache TTL.
|
|
10819
|
+
if (!env.apiModels?.length) {
|
|
10820
|
+
const previous = this.readCliEnv(backend);
|
|
10821
|
+
if (previous?.apiModels?.length)
|
|
10822
|
+
env.apiModels = previous.apiModels;
|
|
10823
|
+
}
|
|
10824
|
+
const path = this.cliEnvPath(backend);
|
|
10825
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
10826
|
+
writeFileSync(path, JSON.stringify(env, null, 2));
|
|
10827
|
+
return env;
|
|
10828
|
+
}
|
|
10460
10829
|
/** Background-probe every distinct backend in use at startup (non-blocking). */
|
|
10461
10830
|
probeCliEnvs() {
|
|
10462
10831
|
const backends = new Set();
|