@agentconnect.md/daemon 1.0.0-rc.237 → 1.0.0-rc.239

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.
@@ -6994,7 +6994,7 @@ var SessionManager = class {
6994
6994
  async handle(agentId, msg) {
6995
6995
  const agent = this.deps.agentById(agentId);
6996
6996
  if (!agent) throw new Error(`unknown agent ${agentId}`);
6997
- ensureMemory(agent.dir, agent.name);
6997
+ this.deps.memory.ensure({ agentId }, agent.name);
6998
6998
  const { thread, ts: coordTs } = transcriptCoords(msg);
6999
6999
  const ts = msg.platform === "webchat" ? monotonicTs() : coordTs;
7000
7000
  const key = sessionKey(msg.platform, msg.channel, thread, agentId);
@@ -7088,7 +7088,7 @@ var SessionManager = class {
7088
7088
  });
7089
7089
  }
7090
7090
  if (created) {
7091
- const index = (await readIndex(agent.dir)).trim();
7091
+ const index = (await this.deps.memory.injectAtSessionStart({ agentId })).trim();
7092
7092
  if (index) blocks.push({
7093
7093
  type: "text",
7094
7094
  text: `# Your memory\nThis is your memory INDEX from earlier sessions. Read a linked topic file with the \`readMemory\` tool when relevant; revise memory with \`writeMemory\`.\n\n${index}`
@@ -7263,25 +7263,16 @@ function guessMimeFromUrl(url) {
7263
7263
  */
7264
7264
  async function executeTool(ctx, name, args, deps) {
7265
7265
  if (name === "listMemory" || name === "readMemory" || name === "writeMemory") {
7266
- const dir = deps.memoryDirFor(ctx.agentId);
7267
- if (!dir) throw new Error(`unknown agent ${ctx.agentId}`);
7266
+ const scope = { agentId: ctx.agentId };
7268
7267
  try {
7269
- if (name === "listMemory") return { files: await listMemory(dir) };
7268
+ if (name === "listMemory") return { files: await deps.memory.list(scope) };
7270
7269
  if (name === "readMemory") {
7271
7270
  const path = optionalString(args, "path") ?? "MEMORY.md";
7272
- return {
7273
- path,
7274
- content: await readMemoryFile(dir, path)
7275
- };
7271
+ return await deps.memory.read(scope, path);
7276
7272
  }
7277
7273
  const path = optionalString(args, "path") ?? "MEMORY.md";
7278
- const { size, mtime } = await writeMemoryFile(dir, path, requireStringAllowEmpty(args, "content"));
7279
- return {
7280
- ok: true,
7281
- path,
7282
- size,
7283
- mtime
7284
- };
7274
+ const content = requireStringAllowEmpty(args, "content");
7275
+ return await deps.memory.write(scope, path, content);
7285
7276
  } catch (err) {
7286
7277
  if (err instanceof MemoryPathError) throw new Error(`invalid memory path: ${err.message}`);
7287
7278
  if (err instanceof MemoryTooLargeError) throw new Error(err.message);
@@ -67655,7 +67646,7 @@ function clampTo$2(s, max) {
67655
67646
  const t = s.trim();
67656
67647
  return t.length > max ? `${t.slice(0, max - 1)}…` : t;
67657
67648
  }
67658
- function clampLabel(s) {
67649
+ function clampLabel$1(s) {
67659
67650
  return clampTo$2(s, MAX_LABEL$2);
67660
67651
  }
67661
67652
  /** Wrap tool activity (a command line, tool title, or tool output) in a CommonMark code
@@ -67722,7 +67713,7 @@ function planIcon$2(status) {
67722
67713
  /** Render an ACP plan (full entry list, resent on every update) as a compact summary.
67723
67714
  * Posted as a `markdown` block, so emphasis is CommonMark (`**bold**`), not mrkdwn. */
67724
67715
  function renderPlan$2(entries) {
67725
- return [":clipboard: **Plan**", ...entries.map((e) => `${planIcon$2(e.status)} ${clampLabel(e.content ?? "")}`)].join("\n");
67716
+ return [":clipboard: **Plan**", ...entries.map((e) => `${planIcon$2(e.status)} ${clampLabel$1(e.content ?? "")}`)].join("\n");
67726
67717
  }
67727
67718
  /** Render the accumulated reasoning trace (high mode) as one in-place message. Posted as
67728
67719
  * a `markdown` block (verbatim CommonMark, so `**bold**`), tail-clamped to a compact cap
@@ -67733,7 +67724,7 @@ function renderReasoning$2(buf) {
67733
67724
  return `:thought_balloon: **Thinking**\n${trimmed.length > MAX_REASONING$2 ? `…${trimmed.slice(-2800)}` : trimmed}`;
67734
67725
  }
67735
67726
  /** Compact a token count as `1.2k` / `3.4M` (whole numbers under 1000 stay verbatim). */
67736
- function compactCount$1(n) {
67727
+ function compactCount$2(n) {
67737
67728
  if (n < 1e3) return String(n);
67738
67729
  if (n < 1e6) return `${(n / 1e3).toFixed(n < 1e4 ? 1 : 0)}k`;
67739
67730
  return `${(n / 1e6).toFixed(1)}M`;
@@ -67749,9 +67740,9 @@ function renderStatusBar(info) {
67749
67740
  if (info.fastMode) parts.push("fast");
67750
67741
  if (info.contextUsed !== void 0 && info.contextSize !== void 0 && info.contextSize > 0) {
67751
67742
  const pct = Math.round(info.contextUsed / info.contextSize * 100);
67752
- parts.push(`ctx ${compactCount$1(info.contextUsed)}/${compactCount$1(info.contextSize)} (${pct}%)`);
67753
- } else if (info.contextUsed !== void 0) parts.push(`ctx ${compactCount$1(info.contextUsed)}`);
67754
- if (info.totalTokens !== void 0) parts.push(`${compactCount$1(info.totalTokens)} tok`);
67743
+ parts.push(`ctx ${compactCount$2(info.contextUsed)}/${compactCount$2(info.contextSize)} (${pct}%)`);
67744
+ } else if (info.contextUsed !== void 0) parts.push(`ctx ${compactCount$2(info.contextUsed)}`);
67745
+ if (info.totalTokens !== void 0) parts.push(`${compactCount$2(info.totalTokens)} tok`);
67755
67746
  return `:bar_chart: ${parts.length ? parts.join(" · ") : "—"}`;
67756
67747
  }
67757
67748
  /** Slack action_ids for the interactive status bar + modal (matched by the connection's
@@ -195004,12 +194995,14 @@ function humanizeDiscordText(text) {
195004
194995
  /** Normalize a Discord gateway message into the platform-agnostic contract. */
195005
194996
  function normalizeDiscordMessage(msg, ctx) {
195006
194997
  const attachments = msg.attachments.map(toAttachment).filter((a) => a !== null);
194998
+ const isDm = !msg.inGuild;
195007
194999
  return {
195008
195000
  msgId: `discord:${msg.channelId}:${msg.id}`,
195009
195001
  traceId: ctx.traceId,
195010
195002
  source: "user",
195011
195003
  platform: "discord",
195012
195004
  channel: msg.channelId,
195005
+ thread: msg.channelId,
195013
195006
  sender: {
195014
195007
  id: msg.authorId,
195015
195008
  isBot: msg.authorIsBot
@@ -195017,10 +195010,101 @@ function normalizeDiscordMessage(msg, ctx) {
195017
195010
  text: humanizeDiscordText(msg.content),
195018
195011
  mentionedBots: msg.mentionUserIds,
195019
195012
  ...attachments.length ? { attachments } : {},
195020
- isDm: !msg.inGuild
195013
+ isDm,
195014
+ ...!isDm && !msg.isThread ? { discordTopLevel: true } : {}
195021
195015
  };
195022
195016
  }
195023
195017
  //#endregion
195018
+ //#region src/discord/app-commands.ts
195019
+ /**
195020
+ * Native Discord application (slash) command definitions, registered globally on
195021
+ * connect (see DiscordConnection.start → registerCommands). They make the same
195022
+ * control vocabulary the daemon already parses from plain `/text` messages
195023
+ * (see commands.ts) show up in Discord's `/` autocomplete menu with descriptions
195024
+ * and typed arguments.
195025
+ *
195026
+ * These are NOT a second command engine: a slash invocation is reconstructed back
195027
+ * into its `/text` form (DiscordConnection.slashCommandText) and fed through the
195028
+ * SAME onInbound → parseCommand → handleCommand path as a typed message, so the
195029
+ * names/args here MUST stay in lockstep with parseCommand's vocabulary (guarded by
195030
+ * discord-app-commands.test.ts).
195031
+ *
195032
+ * PREREQUISITE: the bot must be invited with the `applications.commands` OAuth2
195033
+ * scope, else `commands.set` fails with 403 Missing Access and nothing appears.
195034
+ *
195035
+ * The select commands (`/models` `/effort` `/permission`) take an OPTIONAL free-text
195036
+ * argument — omitting it lists the choices as a tappable button card (buildDiscord-
195037
+ * SelectComponents); passing it selects directly. Choices aren't pre-declared because
195038
+ * the offered options are model-dependent and only known once a session is warm.
195039
+ */
195040
+ const DISCORD_APP_COMMANDS = [
195041
+ {
195042
+ name: "status",
195043
+ description: "Show this session's model, context and token usage"
195044
+ },
195045
+ {
195046
+ name: "models",
195047
+ description: "List models, or switch (leave blank for a tappable picker)",
195048
+ options: [{
195049
+ name: "model",
195050
+ description: "Model id, name, or list number",
195051
+ type: import_src.ApplicationCommandOptionType.String
195052
+ }]
195053
+ },
195054
+ {
195055
+ name: "effort",
195056
+ description: "List reasoning-effort levels, or switch",
195057
+ options: [{
195058
+ name: "level",
195059
+ description: "Effort level or list number",
195060
+ type: import_src.ApplicationCommandOptionType.String
195061
+ }]
195062
+ },
195063
+ {
195064
+ name: "permission",
195065
+ description: "List permission modes, or switch",
195066
+ options: [{
195067
+ name: "mode",
195068
+ description: "Permission mode or list number",
195069
+ type: import_src.ApplicationCommandOptionType.String
195070
+ }]
195071
+ },
195072
+ {
195073
+ name: "fast",
195074
+ description: "Toggle fast mode for this session",
195075
+ options: [{
195076
+ name: "state",
195077
+ description: "on or off",
195078
+ type: import_src.ApplicationCommandOptionType.String,
195079
+ choices: [{
195080
+ name: "on",
195081
+ value: "on"
195082
+ }, {
195083
+ name: "off",
195084
+ value: "off"
195085
+ }]
195086
+ }]
195087
+ },
195088
+ {
195089
+ name: "stop",
195090
+ description: "Stop the agent and mute this thread until @mentioned"
195091
+ },
195092
+ {
195093
+ name: "cancel",
195094
+ description: "Cancel the in-flight turn (session stays live)"
195095
+ },
195096
+ {
195097
+ name: "queue",
195098
+ description: "Queue a message to run once the agent is idle",
195099
+ options: [{
195100
+ name: "message",
195101
+ description: "The message to queue",
195102
+ type: import_src.ApplicationCommandOptionType.String,
195103
+ required: true
195104
+ }]
195105
+ }
195106
+ ];
195107
+ //#endregion
195024
195108
  //#region src/discord/render.ts
195025
195109
  /** Discord single-message hard cap (2000 chars) — vs Telegram's 4096, Slack's 12000-char block. */
195026
195110
  const DISCORD_MESSAGE_LIMIT = 2e3;
@@ -195100,6 +195184,23 @@ function renderReasoning$1(buf) {
195100
195184
  const trimmed = buf.trim();
195101
195185
  return `💭 **Thinking**\n${trimmed.length > MAX_REASONING$1 ? `…${trimmed.slice(-2800)}` : trimmed}`;
195102
195186
  }
195187
+ function compactCount$1(n) {
195188
+ if (n < 1e3) return String(n);
195189
+ if (n < 1e6) return `${(n / 1e3).toFixed(n < 1e4 ? 1 : 0)}k`;
195190
+ return `${(n / 1e6).toFixed(1)}M`;
195191
+ }
195192
+ /** The compact status line (markdown), e.g. `📊 **opus-4.8** · fast · ctx 120k/200k (60%) · 45.2k tok`. */
195193
+ function renderStatusText$1(info) {
195194
+ const parts = [];
195195
+ if (info.model) parts.push(`**${info.model}**`);
195196
+ if (info.fastMode) parts.push("fast");
195197
+ if (info.contextUsed !== void 0 && info.contextSize !== void 0 && info.contextSize > 0) {
195198
+ const pct = Math.round(info.contextUsed / info.contextSize * 100);
195199
+ parts.push(`ctx ${compactCount$1(info.contextUsed)}/${compactCount$1(info.contextSize)} (${pct}%)`);
195200
+ } else if (info.contextUsed !== void 0) parts.push(`ctx ${compactCount$1(info.contextUsed)}`);
195201
+ if (info.totalTokens !== void 0) parts.push(`${compactCount$1(info.totalTokens)} tok`);
195202
+ return `📊 ${parts.length ? parts.join(" · ") : "—"}`;
195203
+ }
195103
195204
  /** custom_id verbs for the status button row. Discord caps custom_id at 100 chars
195104
195205
  * but we keep the verb STATELESS (mirroring Telegram's callback_data) — the
195105
195206
  * connection maps the button's message (channel+message_id) back to its session
@@ -195123,6 +195224,78 @@ function parseDiscordCallback(data) {
195123
195224
  };
195124
195225
  return null;
195125
195226
  }
195227
+ /** One-char code ↔ kind for the compact select custom_id (`ac_sel:<code>:<index>`). */
195228
+ const DISCORD_SELECT_CODE = {
195229
+ model: "m",
195230
+ effort: "e",
195231
+ permission: "p"
195232
+ };
195233
+ const SELECT_CODE_KIND$1 = {
195234
+ m: "model",
195235
+ e: "effort",
195236
+ p: "permission"
195237
+ };
195238
+ const SELECT_PREFIX = "ac_sel";
195239
+ /** Discord caps a button label at 80 chars and an action row at 5 buttons / a message
195240
+ * at 5 rows → 25 options fit as a card; past that the daemon falls back to a text list. */
195241
+ const MAX_BUTTON_LABEL = 80;
195242
+ const MAX_BUTTONS_PER_ROW = 5;
195243
+ const MAX_SELECT_OPTIONS = MAX_BUTTONS_PER_ROW * 5;
195244
+ function clampLabel(s) {
195245
+ return s.length > MAX_BUTTON_LABEL ? `${s.slice(0, MAX_BUTTON_LABEL - 1)}…` : s;
195246
+ }
195247
+ /** Parse a select-button custom_id (`ac_sel:<code>:<index>`) into a kind + option index
195248
+ * (null if it isn't one). Stateless like the status verbs — the connection maps the
195249
+ * button's message back to its session key (see parseDiscordCallback's note). */
195250
+ function parseDiscordSelect(data) {
195251
+ const m = new RegExp(`^${SELECT_PREFIX}:([mep]):(\\d+)$`).exec(data);
195252
+ if (!m) return null;
195253
+ const kind = SELECT_CODE_KIND$1[m[1]];
195254
+ const index = Number(m[2]);
195255
+ if (!kind || !Number.isInteger(index)) return null;
195256
+ return {
195257
+ kind,
195258
+ index
195259
+ };
195260
+ }
195261
+ /**
195262
+ * Build a `/models` `/effort` `/permission` select as tappable button rows — one button
195263
+ * per option (the current flagged ✅, styled Success), custom_id = `ac_sel:<code>:<index>`.
195264
+ * The Discord analog of the daemon's buildSelectCard (Telegram inline keyboard). Returns
195265
+ * null when there are no options or too many to fit Discord's 25-button ceiling, so the
195266
+ * caller falls back to a numbered text list.
195267
+ */
195268
+ function buildDiscordSelectComponents(kind, current, options) {
195269
+ if (options.length === 0 || options.length > MAX_SELECT_OPTIONS) return null;
195270
+ const code = DISCORD_SELECT_CODE[kind];
195271
+ const rows = [];
195272
+ for (let i = 0; i < options.length; i += MAX_BUTTONS_PER_ROW) rows.push({
195273
+ type: 1,
195274
+ components: options.slice(i, i + MAX_BUTTONS_PER_ROW).map((o, j) => {
195275
+ const isCurrent = o === current;
195276
+ return {
195277
+ type: 2,
195278
+ style: isCurrent ? 3 : 2,
195279
+ label: clampLabel(`${isCurrent ? "✅ " : ""}${o}`),
195280
+ custom_id: `${SELECT_PREFIX}:${code}:${i + j}`
195281
+ };
195282
+ })
195283
+ });
195284
+ return rows;
195285
+ }
195286
+ /** A lone "View session" link-button row (Discord style-5). Used by `/status`, where a
195287
+ * raw Slack `<url|text>` link would render as literal text on Discord. */
195288
+ function buildLinkComponents(link, label = "🔗 View session") {
195289
+ return [{
195290
+ type: 1,
195291
+ components: [{
195292
+ type: 2,
195293
+ style: 5,
195294
+ label,
195295
+ url: link
195296
+ }]
195297
+ }];
195298
+ }
195126
195299
  var DiscordConverger = class {
195127
195300
  mode;
195128
195301
  buf = "";
@@ -195307,21 +195480,37 @@ var DiscordConnection = class {
195307
195480
  }
195308
195481
  });
195309
195482
  client.on(import_src.Events.InteractionCreate, (interaction) => {
195483
+ if (interaction.isChatInputCommand()) {
195484
+ this.onSlashCommand(interaction);
195485
+ return;
195486
+ }
195310
195487
  if (!interaction.isButton()) return;
195311
195488
  interaction.deferUpdate().catch(() => {});
195312
- const action = parseDiscordCallback(interaction.customId);
195313
- if (!action) return;
195314
- const sessionKey = this.statusKeys.get(`${interaction.channelId}:${interaction.message.id}`);
195489
+ const channel = interaction.channelId;
195490
+ const sessionKey = this.statusKeys.get(`${channel}:${interaction.message.id}`);
195315
195491
  if (!sessionKey) return;
195316
- if (action.kind === "cancel") this.deps.onStatusAction?.({
195317
- kind: "cancel",
195318
- sessionKey
195319
- });
195320
- else this.deps.onStatusAction?.({
195321
- kind: "set-fast",
195322
- sessionKey,
195323
- fastMode: action.fastMode
195324
- });
195492
+ const status = parseDiscordCallback(interaction.customId);
195493
+ if (status) {
195494
+ if (status.kind === "cancel") this.deps.onStatusAction?.({
195495
+ kind: "cancel",
195496
+ sessionKey
195497
+ });
195498
+ else this.deps.onStatusAction?.({
195499
+ kind: "set-fast",
195500
+ sessionKey,
195501
+ fastMode: status.fastMode
195502
+ });
195503
+ return;
195504
+ }
195505
+ const sel = parseDiscordSelect(interaction.customId);
195506
+ if (sel && this.deps.onSelectAction) {
195507
+ const rendered = this.deps.onSelectAction({
195508
+ kind: sel.kind,
195509
+ index: sel.index,
195510
+ sessionKey
195511
+ });
195512
+ if (rendered) this.updateMessage(channel, interaction.message.id, rendered.text, { keyboard: rendered.components });
195513
+ }
195325
195514
  });
195326
195515
  client.on(import_src.Events.Error, (err) => log?.debug(`discord: client error: ${err.message}`));
195327
195516
  log?.debug("discord: login → opening Gateway…");
@@ -195330,11 +195519,68 @@ var DiscordConnection = class {
195330
195519
  this.botUserId = c.user.id;
195331
195520
  this.botUsername = c.user.username;
195332
195521
  log?.debug(`discord: ready → bot @${this.botUsername} (id ${this.botUserId})`);
195522
+ this.registerCommands();
195333
195523
  resolve();
195334
195524
  });
195335
195525
  client.login(this.botToken).catch(reject);
195336
195526
  });
195337
195527
  }
195528
+ /** Register the control vocabulary as global Discord application commands so it shows
195529
+ * up in the `/` autocomplete menu. Global registration covers every guild + DMs;
195530
+ * first-time propagation can take up to ~1h (edits are near-instant thereafter).
195531
+ * Best-effort — a 403 almost always means the bot wasn't invited with the
195532
+ * `applications.commands` scope, so we log that hint rather than throwing. */
195533
+ async registerCommands() {
195534
+ try {
195535
+ await this.client.application?.commands.set(DISCORD_APP_COMMANDS);
195536
+ this.deps.log?.info(`discord: registered ${DISCORD_APP_COMMANDS.length} slash commands (global) for @${this.botUsername}`);
195537
+ } catch (err) {
195538
+ this.deps.log?.error(`discord: slash-command registration failed — is the bot invited with the applications.commands scope? (${err.message})`);
195539
+ }
195540
+ }
195541
+ /**
195542
+ * A native slash-command invocation. Ack within Discord's 3s window (an ephemeral
195543
+ * echo, seen only by the invoker), then reconstruct the `/text` form and feed it
195544
+ * through the SAME onMessage path as a typed command — so routing, authz, the reply,
195545
+ * and the tappable cards all reuse handleCommand. The real output posts to the
195546
+ * channel/thread (public), matching how a typed `/command` behaves today.
195547
+ */
195548
+ onSlashCommand(interaction) {
195549
+ const text = this.slashCommandText(interaction);
195550
+ interaction.reply({
195551
+ content: text,
195552
+ flags: import_src.MessageFlags.Ephemeral
195553
+ }).catch(() => {});
195554
+ try {
195555
+ const msg = normalizeDiscordMessage(this.slashToLike(interaction, text), { traceId: this.deps.newTraceId() });
195556
+ this.deps.log?.debug(`discord: slash ${text} ch=${msg.channel} user=${msg.sender.id}`);
195557
+ this.deps.onMessage(msg);
195558
+ } catch (err) {
195559
+ this.deps.log?.debug(`discord: slash command failed: ${err.message}`);
195560
+ }
195561
+ }
195562
+ /** Reconstruct a slash invocation into its `/text` form (`/models opus`, `/queue do X`)
195563
+ * so parseCommand handles it exactly like a typed message. Options are appended in
195564
+ * declaration order; our control commands take at most one. */
195565
+ slashCommandText(interaction) {
195566
+ const args = interaction.options.data.map((o) => o.value === void 0 || o.value === null ? "" : String(o.value)).join(" ").trim();
195567
+ return args ? `/${interaction.commandName} ${args}` : `/${interaction.commandName}`;
195568
+ }
195569
+ /** Adapt a slash interaction to the pure normalizer's plain-object view (text = the
195570
+ * reconstructed command). Mirrors toLike; no attachments/mentions on a command. */
195571
+ slashToLike(interaction, text) {
195572
+ return {
195573
+ id: interaction.id,
195574
+ channelId: interaction.channelId,
195575
+ content: text,
195576
+ authorId: interaction.user.id,
195577
+ authorIsBot: interaction.user.bot,
195578
+ inGuild: interaction.inGuild(),
195579
+ isThread: interaction.channel?.isThread() ?? false,
195580
+ mentionUserIds: [],
195581
+ attachments: []
195582
+ };
195583
+ }
195338
195584
  /** Adapt a live gateway Message to the pure normalizer's plain-object view. */
195339
195585
  toLike(message) {
195340
195586
  return {
@@ -195344,6 +195590,7 @@ var DiscordConnection = class {
195344
195590
  authorId: message.author.id,
195345
195591
  authorIsBot: message.author.bot,
195346
195592
  inGuild: message.inGuild(),
195593
+ isThread: message.channel.isThread(),
195347
195594
  mentionUserIds: [...message.mentions.users.keys()],
195348
195595
  attachments: [...message.attachments.values()].map((a) => ({
195349
195596
  id: a.id,
@@ -195361,6 +195608,25 @@ var DiscordConnection = class {
195361
195608
  return null;
195362
195609
  }
195363
195610
  /**
195611
+ * Open a thread off a channel message so a turn's reply + chrome live in the thread
195612
+ * rather than the parent channel (Slack-parity — see daemon's Discord ingress). The
195613
+ * created thread's id EQUALS the source message id (a Discord guarantee), so the
195614
+ * caller re-keys the session onto it and follow-ups posted in the thread (which
195615
+ * arrive with `channelId` = thread id) continue the same session. Best-effort:
195616
+ * returns the thread id, or undefined when the message/channel can't be resolved or
195617
+ * the bot lacks the Create Public Threads permission (caller then replies in-channel).
195618
+ */
195619
+ async createThread(channelId, messageId, name) {
195620
+ try {
195621
+ const ch = await this.sendableChannel(channelId);
195622
+ if (!ch?.messages) return void 0;
195623
+ return (await (await ch.messages.fetch(messageId)).startThread({ name: name.slice(0, 100) || "Agent thread" })).id;
195624
+ } catch (err) {
195625
+ this.deps.log?.debug(`discord: createThread failed (ch=${channelId} msg=${messageId}): ${err.message}`);
195626
+ return;
195627
+ }
195628
+ }
195629
+ /**
195364
195630
  * Post a message. `channel` is already the concrete channel/thread id (Discord
195365
195631
  * threads are their own channels — see normalize.ts), so `threadTs` is unused —
195366
195632
  * the session channel IS the reply target. Long text is chunked to the 2000-char
@@ -197161,6 +197427,65 @@ function createSessionReader(store, workspaceUrlFor) {
197161
197427
  };
197162
197428
  }
197163
197429
  //#endregion
197430
+ //#region src/agents/memory-provider.ts
197431
+ /**
197432
+ * `managed` memory: our `<agent-root>/memory/` directory. A thin, behavior-neutral
197433
+ * facade over `agents/memory.ts` — every method delegates to the existing primitive
197434
+ * and lets its error classes (`MemoryPathError` / `MemoryTooLargeError` /
197435
+ * `MemoryConflictError`) propagate raw, so the MCP + CP error mappings are unchanged.
197436
+ */
197437
+ var ManagedMemoryProvider = class {
197438
+ agentDirByAgent;
197439
+ kind = "managed";
197440
+ /** `agentDirByAgent` resolves an agent id → its root dir (which holds `memory/`),
197441
+ * or undefined for an unknown agent — the same resolver the pre-provider call
197442
+ * sites used. */
197443
+ constructor(agentDirByAgent) {
197444
+ this.agentDirByAgent = agentDirByAgent;
197445
+ }
197446
+ dirFor(agentId) {
197447
+ const dir = this.agentDirByAgent(agentId);
197448
+ if (!dir) throw new Error(`unknown agent ${agentId}`);
197449
+ return dir;
197450
+ }
197451
+ runtimeEnv(_runtime) {
197452
+ return {};
197453
+ }
197454
+ ensure(scope, agentName) {
197455
+ ensureMemory(this.dirFor(scope.agentId), agentName);
197456
+ }
197457
+ async injectAtSessionStart(scope) {
197458
+ return readIndex(this.dirFor(scope.agentId));
197459
+ }
197460
+ async recordTurn(_scope, _turn) {}
197461
+ tools() {
197462
+ return MEMORY_TOOLS;
197463
+ }
197464
+ async list(scope) {
197465
+ return listMemory(this.dirFor(scope.agentId));
197466
+ }
197467
+ async read(scope, path) {
197468
+ return {
197469
+ path,
197470
+ content: await readMemoryFile(this.dirFor(scope.agentId), path)
197471
+ };
197472
+ }
197473
+ async write(scope, path, content, ifMatch) {
197474
+ const { size, mtime } = await writeMemoryFile(this.dirFor(scope.agentId), path, content, ifMatch);
197475
+ return {
197476
+ ok: true,
197477
+ path,
197478
+ size,
197479
+ mtime
197480
+ };
197481
+ }
197482
+ };
197483
+ /** Build the daemon's memory provider. Managed today; the config-driven selection
197484
+ * between native/managed/external is a later phase. */
197485
+ function createManagedMemoryProvider(agentDirByAgent) {
197486
+ return new ManagedMemoryProvider(agentDirByAgent);
197487
+ }
197488
+ //#endregion
197164
197489
  //#region src/cp/workspace-git.ts
197165
197490
  /**
197166
197491
  * `WorkspaceGit` — the seam answering the CP's `workspace/gitstatus` and
@@ -213463,10 +213788,17 @@ function pendingSessionKey(p) {
213463
213788
  channel: p.channel
213464
213789
  };
213465
213790
  }
213791
+ /** Name for a Discord thread opened off a top-level @mention: the first line of the
213792
+ * prompt, collapsed to one line and clamped to Discord's 100-char thread-name cap
213793
+ * (createThread also clamps). Empty prompts (e.g. attachment-only) get a default. */
213794
+ function discordThreadName(text) {
213795
+ return text.replace(/\s+/g, " ").trim().slice(0, 90) || "Agent thread";
213796
+ }
213466
213797
  var Daemon = class Daemon {
213467
213798
  opts;
213468
213799
  store;
213469
213800
  mcp;
213801
+ memory = createManagedMemoryProvider((id) => this.agents.get(id)?.dir);
213470
213802
  gitCreds;
213471
213803
  gitCredServer;
213472
213804
  agents = /* @__PURE__ */ new Map();
@@ -213608,7 +213940,7 @@ var Daemon = class Daemon {
213608
213940
  log: this.log,
213609
213941
  now: () => Date.now(),
213610
213942
  gatewayFor: (integrationId) => this.connByIntegration.get(integrationId) ?? this.tgConnByIntegration.get(integrationId) ?? this.dcConnByIntegration.get(integrationId),
213611
- memoryDirFor: (agentId) => this.agents.get(agentId)?.dir,
213943
+ memory: this.memory,
213612
213944
  recordOutbound: (ctx, channel, thread, text, ts) => this.store.appendTranscript({
213613
213945
  channel,
213614
213946
  thread: thread ?? ctx.thread,
@@ -213625,6 +213957,7 @@ var Daemon = class Daemon {
213625
213957
  store: this.store,
213626
213958
  hostFor: (agentId) => this.ensureHostAsync(agentId),
213627
213959
  agentById: (id) => this.agents.get(id),
213960
+ memory: this.memory,
213628
213961
  mcpServersFor: ({ agent, channel, thread }) => {
213629
213962
  const servers = [];
213630
213963
  const tools = toolsForIntegrations(agent.integrations);
@@ -213966,6 +214299,7 @@ var Daemon = class Daemon {
213966
214299
  newTraceId: () => randomUUID(),
213967
214300
  onMessage: (msg) => this.onInbound(msg),
213968
214301
  onStatusAction: (a) => this.handleStatusAction(a),
214302
+ onSelectAction: (a) => this.handleDiscordSelect(a),
213969
214303
  log: this.log
213970
214304
  });
213971
214305
  try {
@@ -214186,9 +214520,33 @@ var Daemon = class Daemon {
214186
214520
  this.log.info(`routing: agent "${result.agentId}" un-muted in ch=${msg.channel} (explicit @mention)`);
214187
214521
  }
214188
214522
  this.log.info(`routing: ch=${msg.channel} → agent "${result.agentId}" (integration ${result.integrationId})`);
214523
+ if (msg.platform === "discord" && msg.discordTopLevel) {
214524
+ this.dispatchDiscordTopLevel(result.agentId, msg, result.integrationId).catch((err) => this.log.error(`dispatch failed for agent "${result.agentId}": ${formatErr(err)}`));
214525
+ return;
214526
+ }
214189
214527
  this.dispatch(result.agentId, msg, result.integrationId).catch((err) => this.log.error(`dispatch failed for agent "${result.agentId}": ${formatErr(err)}`));
214190
214528
  }
214191
214529
  /**
214530
+ * Discord top-level channel @mention (§Slack-parity threading): open a thread off the
214531
+ * triggering message so the whole turn — reply + progress/status chrome — lands in a
214532
+ * thread instead of flooding the channel. The new thread's id equals the message id, so
214533
+ * we re-key the turn onto the thread channel; every follow-up posted there arrives with
214534
+ * `channelId` = thread id and normalizes to the SAME session, giving the conversation
214535
+ * continuity. Best-effort: if thread creation fails (e.g. the bot lacks Create Public
214536
+ * Threads), we fall back to replying in the channel (the pre-thread behavior).
214537
+ */
214538
+ async dispatchDiscordTopLevel(agentId, msg, integrationId) {
214539
+ const conn = this.dcConnByIntegration.get(integrationId);
214540
+ const messageId = msg.msgId.split(":").pop() ?? "";
214541
+ const threadId = conn ? await conn.createThread(msg.channel, messageId, discordThreadName(msg.text)) : void 0;
214542
+ if (threadId) {
214543
+ this.log.info(`discord: opened thread ${threadId} for ch=${msg.channel} msg=${messageId}`);
214544
+ msg.channel = threadId;
214545
+ msg.thread = threadId;
214546
+ } else this.log.debug(`discord: no thread opened for ch=${msg.channel} — replying in channel`);
214547
+ await this.dispatch(agentId, msg, integrationId);
214548
+ }
214549
+ /**
214192
214550
  * Handle an inbound webchat turn (C→D `webchat/message`). webchat is the one
214193
214551
  * integration whose data plane is the daemon↔CP control WS itself — the far end is
214194
214552
  * the CP/browser, so the body arrives inline and the reply streams back over the
@@ -214388,6 +214746,28 @@ var Daemon = class Daemon {
214388
214746
  if (a.outputMode) this.setOutputModeByKey(a.sessionKey, a.outputMode);
214389
214747
  }
214390
214748
  }
214749
+ /**
214750
+ * A tapped Discord select-card button (`/models` `/effort` `/permission`): resolve the
214751
+ * session from the button's key, apply the chosen option, and return the re-rendered
214752
+ * card so the connection edits the message in place (new current flagged). Undefined
214753
+ * when the session is gone or the option index is stale (options changed) — the
214754
+ * connection then leaves the card as-is. Mirrors handleTelegramCallback.
214755
+ */
214756
+ handleDiscordSelect(a) {
214757
+ const rec = this.store.getSession(a.sessionKey);
214758
+ if (!rec) return void 0;
214759
+ const info = this.statusInfoFrom(rec.agentId, a.sessionKey, rec.acpSessionId ?? void 0);
214760
+ const { options } = this.selectOptions(a.kind, info);
214761
+ const value = options[a.index];
214762
+ if (value === void 0) return void 0;
214763
+ this.applySelect(a.kind, a.sessionKey, value);
214764
+ const components = buildDiscordSelectComponents(a.kind, value, options);
214765
+ if (!components) return void 0;
214766
+ return {
214767
+ text: this.selectCardText(a.kind, value),
214768
+ components
214769
+ };
214770
+ }
214391
214771
  /** All of our own connections' bot identities (both `U…` user ids and `B…`
214392
214772
  * bot_ids), so we recognize self-echoes regardless of which field Slack sends. */
214393
214773
  ownBotIdentities() {
@@ -214532,6 +214912,7 @@ var Daemon = class Daemon {
214532
214912
  threadTs: replyThread,
214533
214913
  replyTo: tgReplyTo
214534
214914
  });
214915
+ else if (msg.platform === "discord") conn?.postChrome(msg.channel, renderStatusText$1(info), link ? { keyboard: buildLinkComponents(link) } : {});
214535
214916
  else reply(link ? `${renderStatusBar(info)} · <${link}|View session>` : renderStatusBar(info));
214536
214917
  return;
214537
214918
  }
@@ -214553,11 +214934,23 @@ var Daemon = class Daemon {
214553
214934
  reply("No active session here to configure.");
214554
214935
  return;
214555
214936
  }
214556
- const card = msg.platform === "telegram" && conn ? (text, buttons) => void conn.postCard(msg.channel, text, buttons, {
214557
- threadTs: replyThread,
214558
- replyTo: tgReplyTo
214559
- }) : void 0;
214560
- this.handleSelectCommand(command.kind, command.value, target.agentId, key, acpSessionId ?? void 0, reply, card);
214937
+ const renderCard = msg.platform === "telegram" && conn ? (kind, current, options) => {
214938
+ const { text, buttons } = this.buildSelectCard(kind, current, options);
214939
+ conn.postCard(msg.channel, text, buttons, {
214940
+ threadTs: replyThread,
214941
+ replyTo: tgReplyTo
214942
+ });
214943
+ return true;
214944
+ } : msg.platform === "discord" && conn ? (kind, current, options) => {
214945
+ const components = buildDiscordSelectComponents(kind, current, options);
214946
+ if (!components) return false;
214947
+ conn.postChrome(msg.channel, this.selectCardText(kind, current), {
214948
+ keyboard: components,
214949
+ sessionKey: key
214950
+ });
214951
+ return true;
214952
+ } : void 0;
214953
+ this.handleSelectCommand(command.kind, command.value, target.agentId, key, acpSessionId ?? void 0, reply, renderCard);
214561
214954
  return;
214562
214955
  }
214563
214956
  if (!command.text) {
@@ -214609,6 +215002,11 @@ var Daemon = class Daemon {
214609
215002
  else if (kind === "effort") this.setEffortByKey(key, value);
214610
215003
  else this.setPermissionModeByKey(key, value);
214611
215004
  }
215005
+ /** Header line for a select card (shared by the Telegram inline-keyboard card and the
215006
+ * Discord button card). */
215007
+ selectCardText(kind, current) {
215008
+ return `${this.selectLabel(kind)} — tap to switch (current: ${current ?? "default"}):`;
215009
+ }
214612
215010
  /** Build a session-control card: a header line + one tappable button per option (the
214613
215011
  * current one flagged), with `callback_data` = `<kindCode>:<optionIndex>`. */
214614
215012
  buildSelectCard(kind, current, options) {
@@ -214618,7 +215016,7 @@ var Daemon = class Daemon {
214618
215016
  callbackData: `${code}:${i}`
214619
215017
  }]);
214620
215018
  return {
214621
- text: `${this.selectLabel(kind)} — tap to switch (current: ${current ?? "default"}):`,
215019
+ text: this.selectCardText(kind, current),
214622
215020
  buttons
214623
215021
  };
214624
215022
  }
@@ -214630,7 +215028,7 @@ var Daemon = class Daemon {
214630
215028
  * live host's config selectors (statusInfoFrom); when the host is cold a given value is
214631
215029
  * accepted optimistically and takes effect on the next turn.
214632
215030
  */
214633
- handleSelectCommand(kind, value, agentId, key, acpSessionId, reply, card) {
215031
+ handleSelectCommand(kind, value, agentId, key, acpSessionId, reply, renderCard) {
214634
215032
  const label = this.selectLabel(kind);
214635
215033
  const info = this.statusInfoFrom(agentId, key, acpSessionId);
214636
215034
  const { current, options } = this.selectOptions(kind, info);
@@ -214640,11 +215038,7 @@ var Daemon = class Daemon {
214640
215038
  reply(current ? `${label}: ${current} (no other options offered${kind === "effort" ? " — the current model may not support effort" : ""}).` : `No ${label.toLowerCase()} options available yet — send me a message first, then try /${cmd} again.`);
214641
215039
  return;
214642
215040
  }
214643
- if (card) {
214644
- const { text, buttons } = this.buildSelectCard(kind, current, options);
214645
- card(text, buttons);
214646
- return;
214647
- }
215041
+ if (renderCard?.(kind, current, options)) return;
214648
215042
  reply(`${label} — reply \`/${cmd} <name or number>\`:\n${options.map((o, i) => `${i + 1}. ${o}${o === current ? " ✓ (current)" : ""}`).join("\n")}`);
214649
215043
  return;
214650
215044
  }
@@ -216065,4 +216459,4 @@ async function runForeground(opts, deps = {}) {
216065
216459
  //#endregion
216066
216460
  export { ClientTransport as i, CP_SUBPROTOCOL as n, CP_WS_PATH as r, runForeground as t };
216067
216461
 
216068
- //# sourceMappingURL=run-foreground-C389T7yc.js.map
216462
+ //# sourceMappingURL=run-foreground-C4Ip8lzS.js.map