@femtomc/mu-server 26.2.100 → 26.2.102

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/README.md CHANGED
@@ -185,6 +185,12 @@ Operational fallbacks:
185
185
  - `telegram_reply_to_message_id` metadata anchors replies when parseable.
186
186
  - Missing Slack/Telegram bot tokens surface capability reason codes (`*_bot_token_missing`) and retry behavior.
187
187
 
188
+ Server channel renderers consume canonical `hud_docs` metadata (`HudDoc`) for Slack/Telegram HUD
189
+ rendering + actions. Optional HUD presentation hints (`title_style`, `snapshot_style`, chip/item styles)
190
+ and metadata presets (`metadata.style_preset`) may be used by richer renderers and safely ignored by
191
+ plain-text channels. New features should extend the shared HUD contract path instead of bespoke
192
+ channel-specific HUD payload formats.
193
+
188
194
  ## Running the Server
189
195
 
190
196
  ### With terminal operator session (recommended)
@@ -53,9 +53,11 @@ export declare function buildTelegramSendMessagePayload(opts: {
53
53
  }): TelegramSendMessagePayload;
54
54
  export declare function splitTelegramMessageText(text: string, maxLen?: number): string[];
55
55
  export declare function splitSlackMessageText(text: string, maxLen?: number): string[];
56
+ type TelegramCallbackDataEncoder = (commandText: string) => Promise<string>;
56
57
  export declare function deliverTelegramOutboxRecord(opts: {
57
58
  botToken: string;
58
59
  record: OutboxRecord;
60
+ encodeCallbackData?: TelegramCallbackDataEncoder;
59
61
  }): Promise<OutboxDeliveryHandlerResult>;
60
62
  export declare function deliverSlackOutboxRecord(opts: {
61
63
  botToken: string;
@@ -1,4 +1,5 @@
1
- import { ControlPlaneCommandPipeline, ControlPlaneOutbox, ControlPlaneRuntime, getControlPlanePaths, TelegramControlPlaneAdapterSpec, } from "@femtomc/mu-control-plane";
1
+ import { applyHudStylePreset, normalizeHudDocs } from "@femtomc/mu-core";
2
+ import { ControlPlaneCommandPipeline, ControlPlaneOutbox, ControlPlaneRuntime, buildSlackHudActionId, getControlPlanePaths, TelegramControlPlaneAdapterSpec, } from "@femtomc/mu-control-plane";
2
3
  import { DEFAULT_MU_CONFIG } from "./config.js";
3
4
  import { buildMessagingOperatorRuntime, createOutboxDrainLoop } from "./control_plane_bootstrap_helpers.js";
4
5
  import { buildWakeOutboundEnvelope, resolveWakeFanoutCapability, wakeDeliveryMetadataFromOutboxRecord, wakeDispatchReasonCode, wakeFanoutDedupeKey, } from "./control_plane_wake_delivery.js";
@@ -141,6 +142,132 @@ export function splitTelegramMessageText(text, maxLen = TELEGRAM_MESSAGE_MAX_LEN
141
142
  return chunks;
142
143
  }
143
144
  const SLACK_MESSAGE_MAX_LEN = 3_500;
145
+ const SLACK_BLOCK_TEXT_MAX_LEN = 3_000;
146
+ const SLACK_BLOCKS_MAX = 50;
147
+ const SLACK_ACTIONS_MAX_PER_BLOCK = 5;
148
+ const SLACK_ACTIONS_MAX_TOTAL = 20;
149
+ const SLACK_DOCS_MAX = 3;
150
+ const SLACK_SECTION_LINE_MAX = 8;
151
+ function hudDocsForPresentation(input, maxDocs) {
152
+ return normalizeHudDocs(input, { maxDocs }).map((doc) => applyHudStylePreset(doc) ?? doc);
153
+ }
154
+ function truncateSlackText(text, maxLen = SLACK_BLOCK_TEXT_MAX_LEN) {
155
+ if (text.length <= maxLen) {
156
+ return text;
157
+ }
158
+ if (maxLen <= 1) {
159
+ return text.slice(0, maxLen);
160
+ }
161
+ return `${text.slice(0, maxLen - 1)}…`;
162
+ }
163
+ function hudTonePrefix(tone) {
164
+ switch (tone) {
165
+ case "success":
166
+ return "✅";
167
+ case "warning":
168
+ return "⚠️";
169
+ case "error":
170
+ return "⛔";
171
+ case "accent":
172
+ return "🔹";
173
+ case "muted":
174
+ return "▫️";
175
+ case "dim":
176
+ return "·";
177
+ case "info":
178
+ default:
179
+ return "ℹ️";
180
+ }
181
+ }
182
+ function applySlackHudTextStyle(text, style, opts = {}) {
183
+ const weight = style?.weight ?? opts.defaultWeight;
184
+ const italic = style?.italic ?? opts.defaultItalic ?? false;
185
+ const code = style?.code ?? opts.defaultCode ?? false;
186
+ let out = text;
187
+ if (code) {
188
+ out = `\`${out}\``;
189
+ }
190
+ if (italic) {
191
+ out = `_${out}_`;
192
+ }
193
+ if (weight === "strong") {
194
+ out = `*${out}*`;
195
+ }
196
+ return out;
197
+ }
198
+ function stripSlackMrkdwn(text) {
199
+ return text.replace(/[*_`~]/g, "");
200
+ }
201
+ function hudDocSectionLines(doc) {
202
+ const lines = [];
203
+ const chipsLine = doc.chips
204
+ .slice(0, 8)
205
+ .map((chip) => {
206
+ const label = applySlackHudTextStyle(chip.label, chip.style, { defaultWeight: "strong" });
207
+ return `${hudTonePrefix(chip.tone)} ${label}`;
208
+ })
209
+ .join(" · ");
210
+ if (chipsLine.length > 0) {
211
+ lines.push(chipsLine);
212
+ }
213
+ for (const section of doc.sections) {
214
+ switch (section.kind) {
215
+ case "kv": {
216
+ const title = applySlackHudTextStyle(section.title ?? "Details", section.title_style, { defaultWeight: "strong" });
217
+ const items = section.items.slice(0, SLACK_SECTION_LINE_MAX).map((item) => {
218
+ const value = applySlackHudTextStyle(item.value, item.value_style);
219
+ return `• *${item.label}:* ${value}`;
220
+ });
221
+ if (items.length > 0) {
222
+ lines.push([title, ...items].join("\n"));
223
+ }
224
+ break;
225
+ }
226
+ case "checklist": {
227
+ const title = applySlackHudTextStyle(section.title ?? "Checklist", section.title_style, { defaultWeight: "strong" });
228
+ const items = section.items
229
+ .slice(0, SLACK_SECTION_LINE_MAX)
230
+ .map((item) => `${item.done ? "✅" : "⬜"} ${applySlackHudTextStyle(item.label, item.style)}`);
231
+ if (items.length > 0) {
232
+ lines.push([title, ...items].join("\n"));
233
+ }
234
+ break;
235
+ }
236
+ case "activity": {
237
+ const title = applySlackHudTextStyle(section.title ?? "Activity", section.title_style, { defaultWeight: "strong" });
238
+ const items = section.lines.slice(0, SLACK_SECTION_LINE_MAX).map((line) => `• ${line}`);
239
+ if (items.length > 0) {
240
+ lines.push([title, ...items].join("\n"));
241
+ }
242
+ break;
243
+ }
244
+ case "text": {
245
+ const title = section.title
246
+ ? `${applySlackHudTextStyle(section.title, section.title_style, { defaultWeight: "strong" })}\n`
247
+ : "";
248
+ const text = applySlackHudTextStyle(section.text, section.style);
249
+ lines.push(`${title}${text}`);
250
+ break;
251
+ }
252
+ }
253
+ }
254
+ if (lines.length === 0) {
255
+ const snapshot = applySlackHudTextStyle(doc.snapshot_compact, doc.snapshot_style);
256
+ lines.push(`${applySlackHudTextStyle("Snapshot", undefined, { defaultWeight: "strong" })}\n${snapshot}`);
257
+ }
258
+ return lines;
259
+ }
260
+ function hudActionButtons(doc) {
261
+ return doc.actions.slice(0, SLACK_ACTIONS_MAX_TOTAL).map((action) => ({
262
+ type: "button",
263
+ text: {
264
+ type: "plain_text",
265
+ text: truncateSlackText(action.label, 75),
266
+ },
267
+ value: truncateSlackText(action.command_text, 2_000),
268
+ action_id: buildSlackHudActionId(action.id),
269
+ }));
270
+ }
144
271
  export function splitSlackMessageText(text, maxLen = SLACK_MESSAGE_MAX_LEN) {
145
272
  if (text.length <= maxLen) {
146
273
  return [text];
@@ -161,8 +288,46 @@ export function splitSlackMessageText(text, maxLen = SLACK_MESSAGE_MAX_LEN) {
161
288
  }
162
289
  return chunks;
163
290
  }
164
- function slackBlocksForOutboxRecord(_record, _body) {
165
- return undefined;
291
+ function slackBlocksForOutboxRecord(record, body) {
292
+ const hudDocs = hudDocsForPresentation(record.envelope.metadata?.hud_docs, SLACK_DOCS_MAX);
293
+ if (hudDocs.length === 0) {
294
+ return undefined;
295
+ }
296
+ const blocks = [];
297
+ blocks.push({
298
+ type: "section",
299
+ text: { type: "mrkdwn", text: truncateSlackText(body) },
300
+ });
301
+ for (const doc of hudDocs) {
302
+ if (blocks.length >= SLACK_BLOCKS_MAX) {
303
+ break;
304
+ }
305
+ const styledTitle = applySlackHudTextStyle(doc.title, doc.title_style, { defaultWeight: "strong" });
306
+ blocks.push({
307
+ type: "context",
308
+ elements: [{ type: "mrkdwn", text: truncateSlackText(`*HUD* · ${styledTitle}`) }],
309
+ });
310
+ for (const line of hudDocSectionLines(doc)) {
311
+ if (blocks.length >= SLACK_BLOCKS_MAX) {
312
+ break;
313
+ }
314
+ blocks.push({
315
+ type: "section",
316
+ text: { type: "mrkdwn", text: truncateSlackText(line) },
317
+ });
318
+ }
319
+ const buttons = hudActionButtons(doc);
320
+ for (let idx = 0; idx < buttons.length; idx += SLACK_ACTIONS_MAX_PER_BLOCK) {
321
+ if (blocks.length >= SLACK_BLOCKS_MAX) {
322
+ break;
323
+ }
324
+ blocks.push({
325
+ type: "actions",
326
+ elements: buttons.slice(idx, idx + SLACK_ACTIONS_MAX_PER_BLOCK),
327
+ });
328
+ }
329
+ }
330
+ return blocks.slice(0, SLACK_BLOCKS_MAX);
166
331
  }
167
332
  function slackThreadTsFromMetadata(metadata) {
168
333
  const candidates = [metadata?.slack_thread_ts, metadata?.slack_message_ts, metadata?.thread_ts];
@@ -181,8 +346,63 @@ function slackStatusMessageTsFromMetadata(metadata) {
181
346
  const trimmed = value.trim();
182
347
  return trimmed.length > 0 ? trimmed : undefined;
183
348
  }
184
- function telegramReplyMarkupForOutboxRecord(_record) {
185
- return undefined;
349
+ const TELEGRAM_CALLBACK_DATA_MAX_BYTES = 64;
350
+ const TELEGRAM_ACTIONS_MAX_TOTAL = 20;
351
+ const TELEGRAM_ACTIONS_PER_ROW = 3;
352
+ const TELEGRAM_DOCS_MAX = 2;
353
+ function utf8ByteLength(value) {
354
+ return new TextEncoder().encode(value).length;
355
+ }
356
+ function telegramTextForOutboxRecord(record, body) {
357
+ const hudDocs = hudDocsForPresentation(record.envelope.metadata?.hud_docs, TELEGRAM_DOCS_MAX);
358
+ if (hudDocs.length === 0) {
359
+ return body;
360
+ }
361
+ const lines = [body.trim()];
362
+ for (const doc of hudDocs) {
363
+ lines.push("", `HUD · ${doc.title}`);
364
+ for (const sectionLine of hudDocSectionLines(doc)) {
365
+ lines.push(stripSlackMrkdwn(sectionLine));
366
+ }
367
+ }
368
+ return lines.join("\n").trim();
369
+ }
370
+ async function compileTelegramHudActions(opts) {
371
+ const hudDocs = hudDocsForPresentation(opts.record.envelope.metadata?.hud_docs, TELEGRAM_DOCS_MAX);
372
+ if (hudDocs.length === 0) {
373
+ return { overflowText: "" };
374
+ }
375
+ const buttons = [];
376
+ const overflowLines = [];
377
+ const actions = hudDocs.flatMap((doc) => doc.actions).slice(0, TELEGRAM_ACTIONS_MAX_TOTAL);
378
+ for (const action of actions) {
379
+ let callbackData = action.command_text;
380
+ if (opts.encodeCallbackData) {
381
+ try {
382
+ callbackData = await opts.encodeCallbackData(action.command_text);
383
+ }
384
+ catch {
385
+ overflowLines.push(`• ${action.label}: ${action.command_text}`);
386
+ continue;
387
+ }
388
+ }
389
+ if (utf8ByteLength(callbackData) > TELEGRAM_CALLBACK_DATA_MAX_BYTES) {
390
+ overflowLines.push(`• ${action.label}: ${action.command_text}`);
391
+ continue;
392
+ }
393
+ buttons.push({
394
+ text: action.label.slice(0, 64),
395
+ callback_data: callbackData,
396
+ });
397
+ }
398
+ const inline_keyboard = [];
399
+ for (let idx = 0; idx < buttons.length; idx += TELEGRAM_ACTIONS_PER_ROW) {
400
+ inline_keyboard.push(buttons.slice(idx, idx + TELEGRAM_ACTIONS_PER_ROW));
401
+ }
402
+ return {
403
+ replyMarkup: inline_keyboard.length > 0 ? { inline_keyboard } : undefined,
404
+ overflowText: overflowLines.length > 0 ? `\n\nActions:\n${overflowLines.join("\n")}` : "",
405
+ };
186
406
  }
187
407
  async function postTelegramMessage(botToken, payload) {
188
408
  return await fetch(`https://api.telegram.org/bot${botToken}/sendMessage`, {
@@ -272,12 +492,17 @@ async function sendTelegramTextChunks(opts) {
272
492
  }
273
493
  export async function deliverTelegramOutboxRecord(opts) {
274
494
  const { botToken, record } = opts;
275
- const replyMarkup = telegramReplyMarkupForOutboxRecord(record);
495
+ const hudActions = await compileTelegramHudActions({
496
+ record,
497
+ encodeCallbackData: opts.encodeCallbackData,
498
+ });
499
+ const replyMarkup = hudActions.replyMarkup;
276
500
  const replyToMessageId = maybeParseTelegramMessageId(record.envelope.metadata?.telegram_reply_to_message_id);
501
+ const telegramText = `${telegramTextForOutboxRecord(record, record.envelope.body)}${hudActions.overflowText}`.trim();
277
502
  const fallbackMessagePayload = {
278
503
  ...buildTelegramSendMessagePayload({
279
504
  chatId: record.envelope.channel_conversation_id,
280
- text: record.envelope.body,
505
+ text: telegramText,
281
506
  richFormatting: true,
282
507
  }),
283
508
  ...(replyMarkup ? { reply_markup: replyMarkup } : {}),
@@ -727,6 +952,13 @@ export async function bootstrapControlPlane(opts) {
727
952
  return await deliverTelegramOutboxRecord({
728
953
  botToken: telegramBotToken,
729
954
  record,
955
+ encodeCallbackData: async (commandText) => {
956
+ const active = telegramManager.activeAdapter();
957
+ if (!active) {
958
+ return commandText;
959
+ }
960
+ return await active.issueCallbackToken({ commandText });
961
+ },
730
962
  });
731
963
  },
732
964
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@femtomc/mu-server",
3
- "version": "26.2.100",
3
+ "version": "26.2.102",
4
4
  "description": "HTTP API server for mu control-plane transport/session plus run/activity scheduling coordination.",
5
5
  "keywords": [
6
6
  "mu",
@@ -30,8 +30,8 @@
30
30
  "start": "bun run dist/cli.js"
31
31
  },
32
32
  "dependencies": {
33
- "@femtomc/mu-agent": "26.2.100",
34
- "@femtomc/mu-control-plane": "26.2.100",
35
- "@femtomc/mu-core": "26.2.100"
33
+ "@femtomc/mu-agent": "26.2.102",
34
+ "@femtomc/mu-control-plane": "26.2.102",
35
+ "@femtomc/mu-core": "26.2.102"
36
36
  }
37
37
  }