@songsid/agend 2.1.8-beta.3 → 2.1.8-beta.5

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.
@@ -552,8 +552,8 @@ export declare class FleetManager implements FleetContext, LifecycleContext, Arc
552
552
  /**
553
553
  * Ask the daemon to capture the pane before answering, then wait for any state
554
554
  * report produced after this request. The ordinary query is intentionally
555
- * cache-only (idle gates call it frequently); this opt-in refresh is reserved
556
- * for lifecycle decisions where a stale state would strand UI.
555
+ * cache-only; delivery idle gates opt into their own pane/input proof, and
556
+ * this refresh serves lifecycle decisions where a stale state would strand UI.
557
557
  */
558
558
  private refreshInstanceExecutionState;
559
559
  private deliverWithIdleGate;
@@ -961,7 +961,7 @@ export declare class FleetManager implements FleetContext, LifecycleContext, Arc
961
961
  */
962
962
  private refuseTypedIpc;
963
963
  private dispatchTypedIpc;
964
- /** `list_emojis` / `set_persona_emoji` over IPC; the agent endpoint calls the same two methods. */
964
+ /** `list_emojis` / `set_persona_emoji` / `preview_emojis` over IPC; the agent endpoint calls the same methods. */
965
965
  private handlePersonaEmoji;
966
966
  private handleScheduleCrud;
967
967
  private handleDecisionCrud;
@@ -992,6 +992,7 @@ export declare class FleetManager implements FleetContext, LifecycleContext, Arc
992
992
  handleSetDisplayNameHttp(instance: string, name: string): Promise<unknown>;
993
993
  handleListEmojisHttp(instance: string, refresh: boolean): Promise<unknown>;
994
994
  handleSetPersonaEmojiHttp(instance: string, args: Record<string, unknown>): Promise<unknown>;
995
+ handlePreviewEmojisHttp(instance: string, args: Record<string, unknown>): Promise<unknown>;
995
996
  handleSetDescriptionHttp(instance: string, description: string): Promise<unknown>;
996
997
  private summarizeToolCall;
997
998
  private handleTaskCrud;
@@ -1126,6 +1127,24 @@ export declare class FleetManager implements FleetContext, LifecycleContext, Arc
1126
1127
  * Settings picker (#1005/#1021), so an agent and an operator see one truth.
1127
1128
  */
1128
1129
  listEmojisFor(instanceName: string, refresh?: boolean): Promise<Record<string, unknown>>;
1130
+ /**
1131
+ * `preview_emojis` (#1040): download a few Discord server emojis so an agent
1132
+ * can look at them before it picks one — a name and an id say nothing about
1133
+ * what an emoji looks like. The fleet fetches, not the agent (not every
1134
+ * backend can), and returns a local path per emoji to Read, as
1135
+ * download_attachment does for an attachment.
1136
+ *
1137
+ * SSRF-safe by construction: nothing from the caller reaches a URL. The
1138
+ * caller names emojis; each must be an available one in a server this bot
1139
+ * can draw on (the list set_persona_emoji checks), and the CDN URL is built
1140
+ * from that listed emoji's numeric id. Static PNG, size-capped, cached by id.
1141
+ */
1142
+ previewEmojis(instanceName: string, args: Record<string, unknown>): Promise<Record<string, unknown>>;
1143
+ private static EMOJI_PREVIEW_MAX;
1144
+ private static EMOJI_PREVIEW_MAX_BYTES;
1145
+ private static EMOJI_PREVIEW_TTL_MS;
1146
+ /** One emoji's static PNG under the inbox, reused while fresh; stale ones are pruned. */
1147
+ private fetchEmojiPreview;
1129
1148
  /**
1130
1149
  * `set_persona_emoji`: `instanceName` sets one of its own status emojis —
1131
1150
  * `delivered` unless it names another — in its per-instance `status_emojis`
@@ -17,7 +17,7 @@ const __filename = fileURLToPath(import.meta.url);
17
17
  const __dirname = dirname(__filename);
18
18
  /** Fallback access policy for a channel with no `access:` block — open (no gate). */
19
19
  const DEFAULT_OPEN_ACCESS = { mode: "open", allowed_users: [], max_pending_codes: 0, code_expiry_minutes: 0 };
20
- import { STATUS_EMOJI_CONFIG_KEYS, STATUS_EMOJI_KEYS, STATUS_EMOJI_SUGGESTIONS, TELEGRAM_REACTION_EMOJIS, builtinStatusEmojis, customEmojiValue, normalizeEmoji, previewStatusEmojis, reactionForm, reactionMatchKey, resolveStatusEmojis, statusAvoidList, statusEmojiProblem, 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";
21
21
  import { isProbeableRouteTarget } from "./fleet-context.js";
22
22
  import { loadFleetConfig, loadRawFleetConfig, DEFAULT_COST_GUARD, DEFAULT_DAILY_SUMMARY, DEFAULT_INSTANCE_CONFIG } from "./config.js";
23
23
  import { EventLog } from "./event-log.js";
@@ -1730,7 +1730,10 @@ export class FleetManager {
1730
1730
  const query = () => {
1731
1731
  const ipc = this.instanceIpcClients.get(instanceName);
1732
1732
  if (ipc?.connected) {
1733
- ipc.send({ type: "query_instance_state", requestId: `idle-gate-${Date.now()}` });
1733
+ // The daemon owns the backend/TTY proof and its 10s startup-input
1734
+ // timer. A cache-only query can never discover a Context-less idle
1735
+ // pane, and a second fleet timer would stack another 10s wait.
1736
+ ipc.send({ type: "query_instance_state", requestId: `idle-gate-${Date.now()}`, deliveryIdle: true });
1734
1737
  }
1735
1738
  check();
1736
1739
  };
@@ -1745,8 +1748,8 @@ export class FleetManager {
1745
1748
  /**
1746
1749
  * Ask the daemon to capture the pane before answering, then wait for any state
1747
1750
  * report produced after this request. The ordinary query is intentionally
1748
- * cache-only (idle gates call it frequently); this opt-in refresh is reserved
1749
- * for lifecycle decisions where a stale state would strand UI.
1751
+ * cache-only; delivery idle gates opt into their own pane/input proof, and
1752
+ * this refresh serves lifecycle decisions where a stale state would strand UI.
1750
1753
  */
1751
1754
  refreshInstanceExecutionState(instanceName, timeoutMs) {
1752
1755
  const ipc = this.instanceIpcClients.get(instanceName);
@@ -5851,7 +5854,7 @@ export class FleetManager {
5851
5854
  : type.startsWith("fleet_decision_") ? "fleet_decision_response"
5852
5855
  : type === "fleet_task" ? "fleet_task_response"
5853
5856
  : type === "fleet_set_display_name" ? "fleet_display_name_response"
5854
- : type === "fleet_list_emojis" || type === "fleet_set_persona_emoji" ? "fleet_persona_emoji_response"
5857
+ : type === "fleet_list_emojis" || type === "fleet_set_persona_emoji" || type === "fleet_preview_emojis" ? "fleet_persona_emoji_response"
5855
5858
  : "fleet_description_response";
5856
5859
  ipc.send({ type: responseType, fleetRequestId, error: message });
5857
5860
  }
@@ -5877,21 +5880,21 @@ export class FleetManager {
5877
5880
  this.handleSetDescription(name, msg);
5878
5881
  return;
5879
5882
  }
5880
- if (type === "fleet_list_emojis" || type === "fleet_set_persona_emoji") {
5883
+ if (type === "fleet_list_emojis" || type === "fleet_set_persona_emoji" || type === "fleet_preview_emojis") {
5881
5884
  this.handlePersonaEmoji(name, msg);
5882
5885
  return;
5883
5886
  }
5884
5887
  }
5885
- /** `list_emojis` / `set_persona_emoji` over IPC; the agent endpoint calls the same two methods. */
5888
+ /** `list_emojis` / `set_persona_emoji` / `preview_emojis` over IPC; the agent endpoint calls the same methods. */
5886
5889
  handlePersonaEmoji(instanceName, msg) {
5887
5890
  const fleetRequestId = msg.fleetRequestId;
5888
5891
  const payload = (msg.payload ?? {});
5889
5892
  const ipc = this.instanceIpcClients.get(instanceName);
5890
5893
  if (!ipc)
5891
5894
  return;
5892
- const op = msg.type === "fleet_list_emojis"
5893
- ? this.listEmojisFor(instanceName, payload.refresh === true)
5894
- : this.setPersonaEmoji(instanceName, payload);
5895
+ const op = msg.type === "fleet_list_emojis" ? this.listEmojisFor(instanceName, payload.refresh === true)
5896
+ : msg.type === "fleet_preview_emojis" ? this.previewEmojis(instanceName, payload)
5897
+ : this.setPersonaEmoji(instanceName, payload);
5895
5898
  op.then((r) => typeof r.error === "string"
5896
5899
  ? ipc.send({ type: "fleet_persona_emoji_response", fleetRequestId, error: r.error })
5897
5900
  : ipc.send({ type: "fleet_persona_emoji_response", fleetRequestId, result: r }), (err) => ipc.send({ type: "fleet_persona_emoji_response", fleetRequestId, error: err.message }));
@@ -6243,6 +6246,9 @@ export class FleetManager {
6243
6246
  async handleSetPersonaEmojiHttp(instance, args) {
6244
6247
  return this.setPersonaEmoji(instance, args);
6245
6248
  }
6249
+ async handlePreviewEmojisHttp(instance, args) {
6250
+ return this.previewEmojis(instance, args);
6251
+ }
6246
6252
  async handleSetDescriptionHttp(instance, description) {
6247
6253
  if (!this.fleetConfig)
6248
6254
  return { error: "Fleet config not available" };
@@ -6982,12 +6988,117 @@ export class FleetManager {
6982
6988
  const listed = await this.listGuildEmojis(worldId, refresh);
6983
6989
  out.server_emojis = listed.ok
6984
6990
  ? listed.guilds.map(g => g.emojis
6985
- ? { server: g.name || g.id, primary: g.primary, emojis: g.emojis.filter(e => e.available).map(e => customEmojiValue(e)) }
6991
+ ? {
6992
+ server: g.name || g.id, primary: g.primary,
6993
+ emojis: g.emojis.filter(e => e.available).map(e => {
6994
+ const value = customEmojiValue(e);
6995
+ return { value, image_url: emojiImageUrl(value) };
6996
+ }),
6997
+ }
6986
6998
  : { server: g.name || g.id, primary: g.primary, error: g.error })
6987
6999
  : { error: listed.error };
6988
7000
  }
6989
7001
  return out;
6990
7002
  }
7003
+ /**
7004
+ * `preview_emojis` (#1040): download a few Discord server emojis so an agent
7005
+ * can look at them before it picks one — a name and an id say nothing about
7006
+ * what an emoji looks like. The fleet fetches, not the agent (not every
7007
+ * backend can), and returns a local path per emoji to Read, as
7008
+ * download_attachment does for an attachment.
7009
+ *
7010
+ * SSRF-safe by construction: nothing from the caller reaches a URL. The
7011
+ * caller names emojis; each must be an available one in a server this bot
7012
+ * can draw on (the list set_persona_emoji checks), and the CDN URL is built
7013
+ * from that listed emoji's numeric id. Static PNG, size-capped, cached by id.
7014
+ */
7015
+ async previewEmojis(instanceName, args) {
7016
+ const self = this.fleetConfig?.instances[instanceName];
7017
+ if (!self)
7018
+ return { error: this.personaEmojiMissing(instanceName) };
7019
+ const wanted = args.emojis;
7020
+ if (!Array.isArray(wanted) || wanted.length === 0 || wanted.some(e => typeof e !== "string")) {
7021
+ return { error: "emojis is required: a list of <:name:id> values from list_emojis" };
7022
+ }
7023
+ if (wanted.length > FleetManager.EMOJI_PREVIEW_MAX) {
7024
+ return { error: `at most ${FleetManager.EMOJI_PREVIEW_MAX} at a time: narrow them down by name first` };
7025
+ }
7026
+ const { platform } = this.resolveStatusEmojisFor(instanceName);
7027
+ const worldId = this.getInstanceAdapterId(instanceName);
7028
+ if (platform !== "discord" || !worldId) {
7029
+ return { error: "only Discord server emojis need a preview; standard emojis are what they look like" };
7030
+ }
7031
+ const listed = await this.listGuildEmojis(worldId);
7032
+ if (!listed.ok)
7033
+ return { error: `cannot list this bot's server emojis: ${listed.error}` };
7034
+ const previews = [];
7035
+ const errors = [];
7036
+ for (const raw of wanted) {
7037
+ const e = normalizeEmoji(raw);
7038
+ if (e?.kind !== "custom") {
7039
+ errors.push({ emoji: raw, error: "not a server emoji (a standard emoji needs no preview)" });
7040
+ continue;
7041
+ }
7042
+ const found = listed.emojis.find(g => g.id === e.id);
7043
+ if (!found?.available) {
7044
+ errors.push({ emoji: raw, error: "not a server emoji this bot can use" });
7045
+ continue;
7046
+ }
7047
+ try {
7048
+ previews.push({ emoji: customEmojiValue(found), path: await this.fetchEmojiPreview(found.id) });
7049
+ }
7050
+ catch (err) {
7051
+ errors.push({ emoji: raw, error: `download failed: ${err.message}` });
7052
+ }
7053
+ }
7054
+ return { previews, errors, note: "Read each path to see the emoji (a static PNG)." };
7055
+ }
7056
+ static EMOJI_PREVIEW_MAX = 8;
7057
+ static EMOJI_PREVIEW_MAX_BYTES = 256 * 1024;
7058
+ static EMOJI_PREVIEW_TTL_MS = 24 * 60 * 60_000;
7059
+ /** One emoji's static PNG under the inbox, reused while fresh; stale ones are pruned. */
7060
+ async fetchEmojiPreview(id) {
7061
+ if (!/^\d{15,25}$/.test(id))
7062
+ throw new Error("not a Discord emoji id");
7063
+ const dir = join(this.dataDir, "inbox", "emoji-previews");
7064
+ mkdirSync(dir, { recursive: true });
7065
+ const now = Date.now();
7066
+ for (const name of readdirSync(dir)) {
7067
+ try {
7068
+ if (now - statSync(join(dir, name)).mtimeMs > FleetManager.EMOJI_PREVIEW_TTL_MS)
7069
+ unlinkSync(join(dir, name));
7070
+ }
7071
+ catch { /* raced */ }
7072
+ }
7073
+ const path = join(dir, `${id}.png`);
7074
+ if (existsSync(path))
7075
+ return path;
7076
+ const response = await fetch(`https://cdn.discordapp.com/emojis/${id}.png?size=96`, { signal: AbortSignal.timeout(10_000), redirect: "error" });
7077
+ if (!response.ok)
7078
+ throw new Error(`HTTP ${response.status}`);
7079
+ if (!/^image\/png\b/.test(response.headers.get("content-type") ?? ""))
7080
+ throw new Error("not a PNG");
7081
+ const reader = response.body?.getReader();
7082
+ if (!reader)
7083
+ throw new Error("empty response");
7084
+ const chunks = [];
7085
+ let size = 0;
7086
+ for (;;) {
7087
+ const { done, value } = await reader.read();
7088
+ if (done)
7089
+ break;
7090
+ size += value.byteLength;
7091
+ if (size > FleetManager.EMOJI_PREVIEW_MAX_BYTES) {
7092
+ await reader.cancel();
7093
+ throw new Error("image too large");
7094
+ }
7095
+ chunks.push(value);
7096
+ }
7097
+ const tmp = `${path}.${process.pid}.tmp`;
7098
+ writeFileSync(tmp, Buffer.concat(chunks));
7099
+ renameSync(tmp, path);
7100
+ return path;
7101
+ }
6991
7102
  /**
6992
7103
  * `set_persona_emoji`: `instanceName` sets one of its own status emojis —
6993
7104
  * `delivered` unless it names another — in its per-instance `status_emojis`