@femtomc/mu-server 26.2.95 → 26.2.97

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
@@ -91,6 +91,9 @@ Use `mu store paths --pretty` to resolve `<store>` for the active repo/workspace
91
91
  "memory_index": {
92
92
  "enabled": true,
93
93
  "every_ms": 300000
94
+ },
95
+ "operator": {
96
+ "timeout_ms": 600000
94
97
  }
95
98
  }
96
99
  }
@@ -153,6 +156,7 @@ mu control status --pretty
153
156
  - Discord: `control_plane.adapters.discord.signing_secret`
154
157
  - Telegram: `control_plane.adapters.telegram.webhook_secret`, `bot_token`, `bot_username`
155
158
  - Neovim: `control_plane.adapters.neovim.shared_secret`
159
+ - Optional operator tuning: `control_plane.operator.timeout_ms` (max wall-time per messaging turn, default `600000`).
156
160
 
157
161
  3) Reload live control-plane runtime:
158
162
 
@@ -189,6 +193,13 @@ When validating attachment support end-to-end, use this sequence:
189
193
  Operational fallback expectations:
190
194
 
191
195
  - If media upload fails, Telegram delivery falls back to text `sendMessage`.
196
+ - Telegram text delivery chunks long messages into deterministic in-order `sendMessage` calls to stay below API size limits.
197
+ - When outbound metadata includes `telegram_reply_to_message_id`, Telegram delivery anchors replies to the originating chat message.
198
+ - Invalid/non-integer `telegram_reply_to_message_id` metadata is ignored so delivery degrades gracefully to non-anchored sends.
199
+ - Awaiting-confirmation envelopes include Telegram inline `Confirm`/`Cancel` callback buttons when interaction metadata provides confirmation actions.
200
+ - Telegram callback payloads are contract-limited to `confirm:<command_id>` and `cancel:<command_id>`; unsupported payloads are explicitly rejected.
201
+ - Callback buttons keep parity with command fallback: `/mu confirm <id>` and `/mu cancel <id>` remain valid.
202
+ - Group/supergroup Telegram freeform text is deterministic no-op with guidance; explicit `/mu ...` commands stay actionable.
192
203
  - If Slack/Telegram bot token is missing, channel capability reason codes should report `*_bot_token_missing` and outbound delivery retries rather than hard-crashing runtime.
193
204
 
194
205
  ## Running the Server
package/dist/config.d.ts CHANGED
@@ -23,6 +23,7 @@ export type MuConfig = {
23
23
  provider: string | null;
24
24
  model: string | null;
25
25
  thinking: string | null;
26
+ timeout_ms: number;
26
27
  };
27
28
  memory_index: {
28
29
  enabled: boolean;
@@ -54,6 +55,7 @@ export type MuConfigPatch = {
54
55
  provider?: string | null;
55
56
  model?: string | null;
56
57
  thinking?: string | null;
58
+ timeout_ms?: number;
57
59
  };
58
60
  memory_index?: {
59
61
  enabled?: boolean;
@@ -85,6 +87,7 @@ export type MuConfigPresence = {
85
87
  provider: boolean;
86
88
  model: boolean;
87
89
  thinking: boolean;
90
+ timeout_ms: number;
88
91
  };
89
92
  memory_index: {
90
93
  enabled: boolean;
package/dist/config.js CHANGED
@@ -26,6 +26,7 @@ export const DEFAULT_MU_CONFIG = {
26
26
  provider: null,
27
27
  model: null,
28
28
  thinking: null,
29
+ timeout_ms: 600_000,
29
30
  },
30
31
  memory_index: {
31
32
  enabled: true,
@@ -129,6 +130,9 @@ export function normalizeMuConfig(input) {
129
130
  if ("thinking" in operator) {
130
131
  next.control_plane.operator.thinking = normalizeNullableString(operator.thinking);
131
132
  }
133
+ if ("timeout_ms" in operator) {
134
+ next.control_plane.operator.timeout_ms = normalizeInteger(operator.timeout_ms, next.control_plane.operator.timeout_ms, { min: 1_000, max: 7_200_000 });
135
+ }
132
136
  }
133
137
  const memoryIndex = asRecord(controlPlane.memory_index);
134
138
  if (memoryIndex) {
@@ -214,6 +218,9 @@ function normalizeMuConfigPatch(input) {
214
218
  if ("thinking" in operator) {
215
219
  patch.control_plane.operator.thinking = normalizeNullableString(operator.thinking);
216
220
  }
221
+ if ("timeout_ms" in operator) {
222
+ patch.control_plane.operator.timeout_ms = normalizeInteger(operator.timeout_ms, DEFAULT_MU_CONFIG.control_plane.operator.timeout_ms, { min: 1_000, max: 7_200_000 });
223
+ }
217
224
  if (Object.keys(patch.control_plane.operator).length === 0) {
218
225
  delete patch.control_plane.operator;
219
226
  }
@@ -288,6 +295,9 @@ export function applyMuConfigPatch(base, patchInput) {
288
295
  if ("thinking" in operator) {
289
296
  next.control_plane.operator.thinking = operator.thinking ?? null;
290
297
  }
298
+ if ("timeout_ms" in operator && typeof operator.timeout_ms === "number" && Number.isFinite(operator.timeout_ms)) {
299
+ next.control_plane.operator.timeout_ms = normalizeInteger(operator.timeout_ms, next.control_plane.operator.timeout_ms, { min: 1_000, max: 7_200_000 });
300
+ }
291
301
  }
292
302
  const memoryIndex = patch.control_plane.memory_index;
293
303
  if (memoryIndex) {
@@ -373,6 +383,7 @@ export function muConfigPresence(config) {
373
383
  provider: isPresent(config.control_plane.operator.provider),
374
384
  model: isPresent(config.control_plane.operator.model),
375
385
  thinking: isPresent(config.control_plane.operator.thinking),
386
+ timeout_ms: config.control_plane.operator.timeout_ms,
376
387
  },
377
388
  memory_index: {
378
389
  enabled: config.control_plane.memory_index.enabled,
@@ -4,21 +4,35 @@ import { type ControlPlaneConfig, type ControlPlaneGenerationContext, type Contr
4
4
  import { detectAdapters } from "./control_plane_adapter_registry.js";
5
5
  export type { ActiveAdapter, ControlPlaneConfig, ControlPlaneGenerationContext, ControlPlaneHandle, ControlPlaneSessionLifecycle, ControlPlaneSessionMutationAction, ControlPlaneSessionMutationResult, NotifyOperatorsOpts, NotifyOperatorsResult, TelegramGenerationReloadResult, TelegramGenerationRollbackTrigger, TelegramGenerationSwapHooks, WakeDeliveryEvent, WakeDeliveryObserver, WakeNotifyContext, WakeNotifyDecision, } from "./control_plane_contract.js";
6
6
  export { detectAdapters };
7
+ type TelegramInlineKeyboardButton = {
8
+ text: string;
9
+ callback_data: string;
10
+ };
11
+ type TelegramInlineKeyboardMarkup = {
12
+ inline_keyboard: TelegramInlineKeyboardButton[][];
13
+ };
7
14
  export type TelegramSendMessagePayload = {
8
15
  chat_id: string;
9
16
  text: string;
10
17
  parse_mode?: "Markdown";
11
18
  disable_web_page_preview?: boolean;
19
+ reply_markup?: TelegramInlineKeyboardMarkup;
20
+ reply_to_message_id?: number;
21
+ allow_sending_without_reply?: boolean;
12
22
  };
13
23
  export type TelegramSendPhotoPayload = {
14
24
  chat_id: string;
15
25
  photo: string;
16
26
  caption?: string;
27
+ reply_to_message_id?: number;
28
+ allow_sending_without_reply?: boolean;
17
29
  };
18
30
  export type TelegramSendDocumentPayload = {
19
31
  chat_id: string;
20
32
  document: string;
21
33
  caption?: string;
34
+ reply_to_message_id?: number;
35
+ allow_sending_without_reply?: boolean;
22
36
  };
23
37
  /**
24
38
  * Telegram supports a markdown dialect that uses single markers for emphasis.
@@ -26,12 +40,19 @@ export type TelegramSendDocumentPayload = {
26
40
  * while preserving fenced code blocks verbatim.
27
41
  */
28
42
  export declare function renderTelegramMarkdown(text: string): string;
43
+ /**
44
+ * Slack mrkdwn does not support Markdown headings.
45
+ * Normalize common heading markers while preserving fenced blocks.
46
+ */
47
+ export declare function renderSlackMarkdown(text: string): string;
29
48
  export declare function containsTelegramMathNotation(text: string): boolean;
30
49
  export declare function buildTelegramSendMessagePayload(opts: {
31
50
  chatId: string;
32
51
  text: string;
33
52
  richFormatting: boolean;
34
53
  }): TelegramSendMessagePayload;
54
+ export declare function splitTelegramMessageText(text: string, maxLen?: number): string[];
55
+ export declare function splitSlackMessageText(text: string, maxLen?: number): string[];
35
56
  export declare function deliverTelegramOutboxRecord(opts: {
36
57
  botToken: string;
37
58
  record: OutboxRecord;
@@ -53,6 +53,34 @@ export function renderTelegramMarkdown(text) {
53
53
  }
54
54
  return out.join("\n");
55
55
  }
56
+ /**
57
+ * Slack mrkdwn does not support Markdown headings.
58
+ * Normalize common heading markers while preserving fenced blocks.
59
+ */
60
+ export function renderSlackMarkdown(text) {
61
+ const normalized = text.replaceAll("\r\n", "\n");
62
+ const lines = normalized.split("\n");
63
+ const out = [];
64
+ let inFence = false;
65
+ for (const line of lines) {
66
+ const trimmed = line.trimStart();
67
+ if (trimmed.startsWith("```")) {
68
+ inFence = !inFence;
69
+ out.push(line);
70
+ continue;
71
+ }
72
+ if (inFence) {
73
+ out.push(line);
74
+ continue;
75
+ }
76
+ let next = line;
77
+ next = next.replace(/^#{1,6}\s+(.+)$/, "*$1*");
78
+ next = next.replace(/\*\*(.+?)\*\*/g, "*$1*");
79
+ next = next.replace(/__(.+?)__/g, "_$1_");
80
+ out.push(next);
81
+ }
82
+ return out.join("\n");
83
+ }
56
84
  const TELEGRAM_MATH_PATTERNS = [
57
85
  /\$\$[\s\S]+?\$\$/m,
58
86
  /(^|[^\\])\$[^$\n]+\$/m,
@@ -79,6 +107,155 @@ export function buildTelegramSendMessagePayload(opts) {
79
107
  disable_web_page_preview: true,
80
108
  };
81
109
  }
110
+ const TELEGRAM_MESSAGE_MAX_LEN = 4_096;
111
+ function maybeParseTelegramMessageId(value) {
112
+ if (typeof value === "number" && Number.isFinite(value)) {
113
+ return Math.trunc(value);
114
+ }
115
+ if (typeof value === "string" && /^-?\d+$/.test(value.trim())) {
116
+ const parsed = Number.parseInt(value.trim(), 10);
117
+ if (Number.isFinite(parsed)) {
118
+ return parsed;
119
+ }
120
+ }
121
+ return null;
122
+ }
123
+ export function splitTelegramMessageText(text, maxLen = TELEGRAM_MESSAGE_MAX_LEN) {
124
+ if (text.length <= maxLen) {
125
+ return [text];
126
+ }
127
+ const chunks = [];
128
+ let cursor = 0;
129
+ while (cursor < text.length) {
130
+ const end = Math.min(text.length, cursor + maxLen);
131
+ if (end === text.length) {
132
+ chunks.push(text.slice(cursor));
133
+ break;
134
+ }
135
+ const window = text.slice(cursor, end);
136
+ const splitAtNewline = window.lastIndexOf("\n");
137
+ const splitPoint = splitAtNewline >= Math.floor(maxLen * 0.5) ? cursor + splitAtNewline + 1 : end;
138
+ chunks.push(text.slice(cursor, splitPoint));
139
+ cursor = splitPoint;
140
+ }
141
+ return chunks;
142
+ }
143
+ const SLACK_MESSAGE_MAX_LEN = 3_500;
144
+ export function splitSlackMessageText(text, maxLen = SLACK_MESSAGE_MAX_LEN) {
145
+ if (text.length <= maxLen) {
146
+ return [text];
147
+ }
148
+ const chunks = [];
149
+ let cursor = 0;
150
+ while (cursor < text.length) {
151
+ const end = Math.min(text.length, cursor + maxLen);
152
+ if (end === text.length) {
153
+ chunks.push(text.slice(cursor));
154
+ break;
155
+ }
156
+ const window = text.slice(cursor, end);
157
+ const splitAtNewline = window.lastIndexOf("\n");
158
+ const splitPoint = splitAtNewline >= Math.floor(maxLen * 0.5) ? cursor + splitAtNewline + 1 : end;
159
+ chunks.push(text.slice(cursor, splitPoint));
160
+ cursor = splitPoint;
161
+ }
162
+ return chunks;
163
+ }
164
+ function slackBlocksForOutboxRecord(record, body) {
165
+ const interactionMessage = record.envelope.metadata?.interaction_message;
166
+ if (!interactionMessage || typeof interactionMessage !== "object") {
167
+ return undefined;
168
+ }
169
+ const rawActions = interactionMessage.actions;
170
+ if (!Array.isArray(rawActions) || rawActions.length === 0) {
171
+ return undefined;
172
+ }
173
+ const buttons = [];
174
+ for (const action of rawActions) {
175
+ if (!action || typeof action !== "object") {
176
+ continue;
177
+ }
178
+ const candidate = action;
179
+ if (typeof candidate.label !== "string" || typeof candidate.command !== "string") {
180
+ continue;
181
+ }
182
+ const normalized = candidate.command.trim().replace(/^\/mu\s+/i, "");
183
+ const confirm = /^confirm\s+([^\s]+)$/i.exec(normalized);
184
+ if (confirm?.[1]) {
185
+ buttons.push({
186
+ type: "button",
187
+ text: { type: "plain_text", text: candidate.label },
188
+ value: `confirm:${confirm[1]}`,
189
+ action_id: `confirm_${confirm[1]}`,
190
+ });
191
+ continue;
192
+ }
193
+ const cancel = /^cancel\s+([^\s]+)$/i.exec(normalized);
194
+ if (cancel?.[1]) {
195
+ buttons.push({
196
+ type: "button",
197
+ text: { type: "plain_text", text: candidate.label },
198
+ value: `cancel:${cancel[1]}`,
199
+ action_id: `cancel_${cancel[1]}`,
200
+ });
201
+ }
202
+ }
203
+ if (buttons.length === 0) {
204
+ return undefined;
205
+ }
206
+ return [
207
+ { type: "section", text: { type: "mrkdwn", text: body } },
208
+ { type: "actions", elements: buttons },
209
+ ];
210
+ }
211
+ function slackThreadTsFromMetadata(metadata) {
212
+ const candidates = [metadata?.slack_thread_ts, metadata?.slack_message_ts, metadata?.thread_ts];
213
+ for (const value of candidates) {
214
+ if (typeof value === "string" && value.trim().length > 0) {
215
+ return value.trim();
216
+ }
217
+ }
218
+ return undefined;
219
+ }
220
+ function slackStatusMessageTsFromMetadata(metadata) {
221
+ const value = metadata?.slack_status_message_ts;
222
+ if (typeof value !== "string") {
223
+ return undefined;
224
+ }
225
+ const trimmed = value.trim();
226
+ return trimmed.length > 0 ? trimmed : undefined;
227
+ }
228
+ function telegramReplyMarkupForOutboxRecord(record) {
229
+ const interactionMessage = record.envelope.metadata?.interaction_message;
230
+ if (!interactionMessage || typeof interactionMessage !== "object") {
231
+ return undefined;
232
+ }
233
+ const rawActions = interactionMessage.actions;
234
+ if (!Array.isArray(rawActions) || rawActions.length === 0) {
235
+ return undefined;
236
+ }
237
+ const row = [];
238
+ for (const action of rawActions) {
239
+ if (!action || typeof action !== "object") {
240
+ continue;
241
+ }
242
+ const candidate = action;
243
+ if (typeof candidate.label !== "string" || typeof candidate.command !== "string") {
244
+ continue;
245
+ }
246
+ const normalized = candidate.command.trim().replace(/^\/mu\s+/i, "");
247
+ const confirm = /^confirm\s+([^\s]+)$/i.exec(normalized);
248
+ if (confirm?.[1]) {
249
+ row.push({ text: candidate.label, callback_data: `confirm:${confirm[1]}` });
250
+ continue;
251
+ }
252
+ const cancel = /^cancel\s+([^\s]+)$/i.exec(normalized);
253
+ if (cancel?.[1]) {
254
+ row.push({ text: candidate.label, callback_data: `cancel:${cancel[1]}` });
255
+ }
256
+ }
257
+ return row.length > 0 ? { inline_keyboard: [row] } : undefined;
258
+ }
82
259
  async function postTelegramMessage(botToken, payload) {
83
260
  return await fetch(`https://api.telegram.org/bot${botToken}/sendMessage`, {
84
261
  method: "POST",
@@ -133,37 +310,76 @@ function parseRetryDelayMs(res) {
133
310
  }
134
311
  return parsed * 1000;
135
312
  }
313
+ async function sendTelegramTextChunks(opts) {
314
+ const chunks = splitTelegramMessageText(opts.basePayload.text);
315
+ for (const [index, chunk] of chunks.entries()) {
316
+ const payload = {
317
+ ...opts.basePayload,
318
+ text: chunk,
319
+ ...(index === 0 ? {} : { reply_markup: undefined, reply_to_message_id: undefined, allow_sending_without_reply: undefined }),
320
+ };
321
+ let res = await postTelegramMessage(opts.botToken, payload);
322
+ if (!res.ok && res.status === 400 && payload.parse_mode && opts.fallbackToPlainOnMarkdownError) {
323
+ const plainPayload = buildTelegramSendMessagePayload({
324
+ chatId: payload.chat_id,
325
+ text: payload.text,
326
+ richFormatting: false,
327
+ });
328
+ res = await postTelegramMessage(opts.botToken, {
329
+ ...plainPayload,
330
+ ...(payload.reply_markup ? { reply_markup: payload.reply_markup } : {}),
331
+ ...(payload.reply_to_message_id != null
332
+ ? {
333
+ reply_to_message_id: payload.reply_to_message_id,
334
+ allow_sending_without_reply: payload.allow_sending_without_reply,
335
+ }
336
+ : {}),
337
+ });
338
+ }
339
+ if (!res.ok) {
340
+ return { ok: false, response: res, body: await res.text().catch(() => "") };
341
+ }
342
+ }
343
+ return { ok: true };
344
+ }
136
345
  export async function deliverTelegramOutboxRecord(opts) {
137
346
  const { botToken, record } = opts;
138
- const fallbackMessagePayload = buildTelegramSendMessagePayload({
139
- chatId: record.envelope.channel_conversation_id,
140
- text: record.envelope.body,
141
- richFormatting: true,
142
- });
347
+ const replyMarkup = telegramReplyMarkupForOutboxRecord(record);
348
+ const replyToMessageId = maybeParseTelegramMessageId(record.envelope.metadata?.telegram_reply_to_message_id);
349
+ const fallbackMessagePayload = {
350
+ ...buildTelegramSendMessagePayload({
351
+ chatId: record.envelope.channel_conversation_id,
352
+ text: record.envelope.body,
353
+ richFormatting: true,
354
+ }),
355
+ ...(replyMarkup ? { reply_markup: replyMarkup } : {}),
356
+ ...(replyToMessageId != null
357
+ ? {
358
+ reply_to_message_id: replyToMessageId,
359
+ allow_sending_without_reply: true,
360
+ }
361
+ : {}),
362
+ };
143
363
  const firstAttachment = record.envelope.attachments?.[0] ?? null;
144
364
  if (!firstAttachment) {
145
- let res = await postTelegramMessage(botToken, fallbackMessagePayload);
146
- if (!res.ok && res.status === 400 && fallbackMessagePayload.parse_mode) {
147
- res = await postTelegramMessage(botToken, buildTelegramSendMessagePayload({
148
- chatId: record.envelope.channel_conversation_id,
149
- text: record.envelope.body,
150
- richFormatting: false,
151
- }));
152
- }
153
- if (res.ok) {
365
+ const sent = await sendTelegramTextChunks({
366
+ botToken,
367
+ basePayload: fallbackMessagePayload,
368
+ fallbackToPlainOnMarkdownError: true,
369
+ });
370
+ if (sent.ok) {
154
371
  return { kind: "delivered" };
155
372
  }
156
- const responseBody = await res.text().catch(() => "");
157
- if (res.status === 429 || res.status >= 500) {
373
+ if (sent.response.status === 429 || sent.response.status >= 500) {
158
374
  return {
159
375
  kind: "retry",
160
- error: `telegram sendMessage ${res.status}: ${responseBody}`,
161
- retryDelayMs: parseRetryDelayMs(res),
376
+ error: `telegram sendMessage ${sent.response.status}: ${sent.body}`,
377
+ retryDelayMs: parseRetryDelayMs(sent.response),
162
378
  };
163
379
  }
164
380
  return {
165
381
  kind: "retry",
166
- error: `telegram sendMessage ${res.status}: ${responseBody}`,
382
+ error: `telegram sendMessage ${sent.response.status}: ${sent.body}`,
167
383
  };
168
384
  }
169
385
  const mediaMethod = chooseTelegramMediaMethod(firstAttachment);
@@ -180,11 +396,23 @@ export async function deliverTelegramOutboxRecord(opts) {
180
396
  chat_id: record.envelope.channel_conversation_id,
181
397
  photo: firstAttachment.reference.file_id,
182
398
  caption: mediaCaption,
399
+ ...(replyToMessageId != null
400
+ ? {
401
+ reply_to_message_id: replyToMessageId,
402
+ allow_sending_without_reply: true,
403
+ }
404
+ : {}),
183
405
  }
184
406
  : {
185
407
  chat_id: record.envelope.channel_conversation_id,
186
408
  document: firstAttachment.reference.file_id,
187
409
  caption: mediaCaption,
410
+ ...(replyToMessageId != null
411
+ ? {
412
+ reply_to_message_id: replyToMessageId,
413
+ allow_sending_without_reply: true,
414
+ }
415
+ : {}),
188
416
  });
189
417
  }
190
418
  else {
@@ -206,6 +434,10 @@ export async function deliverTelegramOutboxRecord(opts) {
206
434
  if (mediaCaption.length > 0) {
207
435
  form.append("caption", mediaCaption);
208
436
  }
437
+ if (replyToMessageId != null) {
438
+ form.append("reply_to_message_id", String(replyToMessageId));
439
+ form.append("allow_sending_without_reply", "true");
440
+ }
209
441
  form.append(mediaField, new Blob([body], { type: contentType }), filename);
210
442
  mediaResponse = await postTelegramApiMultipart(botToken, mediaMethod, form);
211
443
  }
@@ -220,26 +452,38 @@ export async function deliverTelegramOutboxRecord(opts) {
220
452
  retryDelayMs: parseRetryDelayMs(mediaResponse),
221
453
  };
222
454
  }
223
- const fallbackPlainPayload = buildTelegramSendMessagePayload({
224
- chatId: record.envelope.channel_conversation_id,
225
- text: record.envelope.body,
226
- richFormatting: false,
455
+ const fallbackPlainPayload = {
456
+ ...buildTelegramSendMessagePayload({
457
+ chatId: record.envelope.channel_conversation_id,
458
+ text: record.envelope.body,
459
+ richFormatting: false,
460
+ }),
461
+ ...(replyMarkup ? { reply_markup: replyMarkup } : {}),
462
+ ...(replyToMessageId != null
463
+ ? {
464
+ reply_to_message_id: replyToMessageId,
465
+ allow_sending_without_reply: true,
466
+ }
467
+ : {}),
468
+ };
469
+ const fallbackSent = await sendTelegramTextChunks({
470
+ botToken,
471
+ basePayload: fallbackPlainPayload,
472
+ fallbackToPlainOnMarkdownError: false,
227
473
  });
228
- const fallbackRes = await postTelegramMessage(botToken, fallbackPlainPayload);
229
- if (fallbackRes.ok) {
474
+ if (fallbackSent.ok) {
230
475
  return { kind: "delivered" };
231
476
  }
232
- const fallbackBody = await fallbackRes.text().catch(() => "");
233
- if (fallbackRes.status === 429 || fallbackRes.status >= 500) {
477
+ if (fallbackSent.response.status === 429 || fallbackSent.response.status >= 500) {
234
478
  return {
235
479
  kind: "retry",
236
- error: `telegram media fallback sendMessage ${fallbackRes.status}: ${fallbackBody}`,
237
- retryDelayMs: parseRetryDelayMs(fallbackRes),
480
+ error: `telegram media fallback sendMessage ${fallbackSent.response.status}: ${fallbackSent.body}`,
481
+ retryDelayMs: parseRetryDelayMs(fallbackSent.response),
238
482
  };
239
483
  }
240
484
  return {
241
485
  kind: "retry",
242
- error: `telegram media fallback sendMessage ${fallbackRes.status}: ${fallbackBody} (media_error=${mediaMethod} ${mediaResponse.status}: ${mediaBody})`,
486
+ error: `telegram media fallback sendMessage ${fallbackSent.response.status}: ${fallbackSent.body} (media_error=${mediaMethod} ${mediaResponse.status}: ${mediaBody})`,
243
487
  };
244
488
  }
245
489
  async function postSlackJson(opts) {
@@ -257,30 +501,75 @@ async function postSlackJson(opts) {
257
501
  export async function deliverSlackOutboxRecord(opts) {
258
502
  const { botToken, record } = opts;
259
503
  const attachments = record.envelope.attachments ?? [];
504
+ const renderedBody = renderSlackMarkdown(record.envelope.body);
505
+ const textChunks = splitSlackMessageText(renderedBody);
506
+ const blocks = slackBlocksForOutboxRecord(record, renderedBody);
507
+ const threadTs = slackThreadTsFromMetadata(record.envelope.metadata);
508
+ const statusMessageTs = slackStatusMessageTsFromMetadata(record.envelope.metadata);
260
509
  if (attachments.length === 0) {
261
- const delivered = await postSlackJson({
262
- botToken,
263
- method: "chat.postMessage",
264
- payload: {
265
- channel: record.envelope.channel_conversation_id,
266
- text: record.envelope.body,
267
- unfurl_links: false,
268
- unfurl_media: false,
269
- },
270
- });
271
- if (delivered.response.ok && delivered.payload?.ok) {
272
- return { kind: "delivered" };
510
+ let chunkStartIndex = 0;
511
+ if (statusMessageTs && textChunks.length > 0) {
512
+ const updated = await postSlackJson({
513
+ botToken,
514
+ method: "chat.update",
515
+ payload: {
516
+ channel: record.envelope.channel_conversation_id,
517
+ ts: statusMessageTs,
518
+ text: textChunks[0],
519
+ unfurl_links: false,
520
+ unfurl_media: false,
521
+ ...(blocks ? { blocks } : {}),
522
+ },
523
+ });
524
+ if (updated.response.ok && updated.payload?.ok) {
525
+ chunkStartIndex = 1;
526
+ }
527
+ else {
528
+ const status = updated.response.status;
529
+ const err = updated.payload?.error ?? "unknown_error";
530
+ if (status === 429 || status >= 500) {
531
+ return {
532
+ kind: "retry",
533
+ error: `slack chat.update ${status}: ${err}`,
534
+ retryDelayMs: parseRetryDelayMs(updated.response),
535
+ };
536
+ }
537
+ if (err !== "message_not_found" && err !== "cant_update_message") {
538
+ return { kind: "retry", error: `slack chat.update ${status}: ${err}` };
539
+ }
540
+ }
273
541
  }
274
- const status = delivered.response.status;
275
- const err = delivered.payload?.error ?? "unknown_error";
276
- if (status === 429 || status >= 500) {
277
- return {
278
- kind: "retry",
279
- error: `slack chat.postMessage ${status}: ${err}`,
280
- retryDelayMs: parseRetryDelayMs(delivered.response),
281
- };
542
+ for (const [index, chunk] of textChunks.entries()) {
543
+ if (index < chunkStartIndex) {
544
+ continue;
545
+ }
546
+ const delivered = await postSlackJson({
547
+ botToken,
548
+ method: "chat.postMessage",
549
+ payload: {
550
+ channel: record.envelope.channel_conversation_id,
551
+ text: chunk,
552
+ unfurl_links: false,
553
+ unfurl_media: false,
554
+ ...(index === 0 && blocks ? { blocks } : {}),
555
+ ...(threadTs ? { thread_ts: threadTs } : {}),
556
+ },
557
+ });
558
+ if (delivered.response.ok && delivered.payload?.ok) {
559
+ continue;
560
+ }
561
+ const status = delivered.response.status;
562
+ const err = delivered.payload?.error ?? "unknown_error";
563
+ if (status === 429 || status >= 500) {
564
+ return {
565
+ kind: "retry",
566
+ error: `slack chat.postMessage ${status}: ${err}`,
567
+ retryDelayMs: parseRetryDelayMs(delivered.response),
568
+ };
569
+ }
570
+ return { kind: "retry", error: `slack chat.postMessage ${status}: ${err}` };
282
571
  }
283
- return { kind: "retry", error: `slack chat.postMessage ${status}: ${err}` };
572
+ return { kind: "delivered" };
284
573
  }
285
574
  let firstError = null;
286
575
  for (const [index, attachment] of attachments.entries()) {
@@ -311,7 +600,10 @@ export async function deliverSlackOutboxRecord(opts) {
311
600
  form.set("filename", filename);
312
601
  form.set("title", filename);
313
602
  if (index === 0 && record.envelope.body.trim().length > 0) {
314
- form.set("initial_comment", record.envelope.body);
603
+ form.set("initial_comment", textChunks[0] ?? record.envelope.body);
604
+ }
605
+ if (threadTs) {
606
+ form.set("thread_ts", threadTs);
315
607
  }
316
608
  form.set("file", new Blob([bytes], { type: contentType }), filename);
317
609
  const uploaded = await fetch("https://slack.com/api/files.upload", {
@@ -337,33 +629,39 @@ export async function deliverSlackOutboxRecord(opts) {
337
629
  }
338
630
  }
339
631
  }
340
- if (firstError) {
341
- const fallback = await postSlackJson({
342
- botToken,
343
- method: "chat.postMessage",
344
- payload: {
345
- channel: record.envelope.channel_conversation_id,
346
- text: record.envelope.body,
347
- unfurl_links: false,
348
- unfurl_media: false,
349
- },
350
- });
351
- if (fallback.response.ok && fallback.payload?.ok) {
352
- return { kind: "delivered" };
353
- }
354
- const status = fallback.response.status;
355
- const err = fallback.payload?.error ?? "unknown_error";
356
- if (status === 429 || status >= 500) {
632
+ if (firstError || textChunks.length > 1) {
633
+ for (const [index, chunk] of textChunks.entries()) {
634
+ if (index === 0 && !firstError) {
635
+ continue;
636
+ }
637
+ const fallback = await postSlackJson({
638
+ botToken,
639
+ method: "chat.postMessage",
640
+ payload: {
641
+ channel: record.envelope.channel_conversation_id,
642
+ text: chunk,
643
+ unfurl_links: false,
644
+ unfurl_media: false,
645
+ ...(threadTs ? { thread_ts: threadTs } : {}),
646
+ },
647
+ });
648
+ if (fallback.response.ok && fallback.payload?.ok) {
649
+ continue;
650
+ }
651
+ const status = fallback.response.status;
652
+ const err = fallback.payload?.error ?? "unknown_error";
653
+ if (status === 429 || status >= 500) {
654
+ return {
655
+ kind: "retry",
656
+ error: `slack chat.postMessage fallback ${status}: ${err}${firstError ? ` (upload_error=${firstError})` : ""}`,
657
+ retryDelayMs: parseRetryDelayMs(fallback.response),
658
+ };
659
+ }
357
660
  return {
358
661
  kind: "retry",
359
- error: `slack chat.postMessage fallback ${status}: ${err} (upload_error=${firstError})`,
360
- retryDelayMs: parseRetryDelayMs(fallback.response),
662
+ error: `slack chat.postMessage fallback ${status}: ${err}${firstError ? ` (upload_error=${firstError})` : ""}`,
361
663
  };
362
664
  }
363
- return {
364
- kind: "retry",
365
- error: `slack chat.postMessage fallback ${status}: ${err} (upload_error=${firstError})`,
366
- };
367
665
  }
368
666
  return { kind: "delivered" };
369
667
  }
@@ -11,6 +11,7 @@ export function buildMessagingOperatorRuntime(opts) {
11
11
  provider: opts.config.operator.provider ?? undefined,
12
12
  model: opts.config.operator.model ?? undefined,
13
13
  thinking: opts.config.operator.thinking ?? undefined,
14
+ timeoutMs: opts.config.operator.timeout_ms,
14
15
  extensionPaths: operatorExtensionPaths,
15
16
  });
16
17
  const conversationSessionStore = new JsonFileConversationSessionStore(join(getControlPlanePaths(opts.repoRoot).controlPlaneDir, "operator_conversations.json"));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@femtomc/mu-server",
3
- "version": "26.2.95",
3
+ "version": "26.2.97",
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.95",
34
- "@femtomc/mu-control-plane": "26.2.95",
35
- "@femtomc/mu-core": "26.2.95"
33
+ "@femtomc/mu-agent": "26.2.97",
34
+ "@femtomc/mu-control-plane": "26.2.97",
35
+ "@femtomc/mu-core": "26.2.97"
36
36
  }
37
37
  }