@xmanrui/dsh-im 4.27.0 → 4.28.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xmanrui/dsh-im",
3
- "version": "4.27.0",
3
+ "version": "4.28.0",
4
4
  "description": "把十二种 IM 渠道和公网 AI Office 接入本机 DeepSeek Harness。 Connect twelve IM channels and a public AI Office to a local DeepSeek Harness.",
5
5
  "keywords": [
6
6
  "deepseek-harness",
@@ -305,6 +305,32 @@ function isFeishuLocalCommand(text, { hasImages = false, hasFiles = false } = {}
305
305
  || ARCHIVED_COMMAND.test(command);
306
306
  }
307
307
 
308
+ /**
309
+ * A p2p text message that is nothing but a mention of the bot.
310
+ *
311
+ * The mention is stripped before we see the text, so `@bot` arrives as an empty
312
+ * body — indistinguishable from an empty message unless the message type is
313
+ * checked. Deliberately narrow:
314
+ *
315
+ * - only `p2p`, where the reader is talking to the bot directly;
316
+ * - only `text`, so a voice note, video, sticker or forwarded card keeps its
317
+ * own "not supported" notice instead of silently opening a menu;
318
+ * - only when nothing else came with it, so `@bot 你好` stays a prompt.
319
+ *
320
+ * The caller also treats this as a command for access control, because opening
321
+ * the menu is `/m` by another route and carries the same information.
322
+ */
323
+ function isBareMentionMenuRequest(event, text, {
324
+ hasImages = false,
325
+ hasFiles = false,
326
+ hasReply = false,
327
+ } = {}) {
328
+ if (event?.message?.chat_type !== 'p2p') return false;
329
+ if (event?.message?.message_type !== 'text') return false;
330
+ if (hasImages || hasFiles || hasReply) return false;
331
+ return !String(text ?? '').trim();
332
+ }
333
+
308
334
  /** Canonical workspace/session help advertised by every bridge family. */
309
335
  const WORKSPACE_HELP_LINES = [
310
336
  '/workspace 工作区序号或绝对路径 切换工作区',
@@ -989,7 +1015,16 @@ export class FeishuHarnessBridge {
989
1015
  hasImages,
990
1016
  hasFiles,
991
1017
  }) || isFeishuLocalCommand(commandText, { hasImages, hasFiles })
992
- || (!hasImages && !hasFiles && NUMBER_REPLY.test(commandText) && this.#menus.has(key)),
1018
+ || (!hasImages && !hasFiles && NUMBER_REPLY.test(commandText) && this.#menus.has(key))
1019
+ // Opening the menu on a bare mention is the `/m` command by another
1020
+ // route, so it must clear the same command gate. Treating it as plain
1021
+ // chat let a sender without `canExecuteCommands` receive a menu that
1022
+ // carries workspace paths and other session titles.
1023
+ || isBareMentionMenuRequest(event, commandText, {
1024
+ hasImages,
1025
+ hasFiles,
1026
+ hasReply: hasReplyReference(commandMessage),
1027
+ }),
993
1028
  });
994
1029
  if (!access.allowed) {
995
1030
  this.#acceptedMessageIds.set(messageId, null);
@@ -1468,6 +1503,14 @@ export class FeishuHarnessBridge {
1468
1503
  // accept() 侧已用 nonEmptyString(content) 判定,两侧保持一致。
1469
1504
  const commandText = !hasImages && !hasFiles && text ? text.trim() : null;
1470
1505
  if (!text && !hasImages && !hasFiles && !hasReply) {
1506
+ // An "@bot" with nothing else carries no instruction to parse, and the
1507
+ // menu card is what the reader is reaching for — answer it the way `/m`
1508
+ // does rather than with a "text only" notice. Kept to plain p2p text so a
1509
+ // voice note, video or forwarded card still gets its own notice.
1510
+ if (isBareMentionMenuRequest(event, text, { hasImages, hasFiles, hasReply })) {
1511
+ await this.#sendMenuCard(key, event.message.chat_id, { replyTo: event.message.message_id });
1512
+ return;
1513
+ }
1471
1514
  await this.#send(event.message.chat_id, t('目前支持文字、图片和文件消息。'), { replyTo: event.message.message_id });
1472
1515
  return;
1473
1516
  }
@@ -3755,6 +3798,7 @@ export class FeishuHarnessBridge {
3755
3798
  Object.assign(card, {
3756
3799
  blocks: structuredClone(entry.blocks), cardIds: [...entry.cardIds],
3757
3800
  messageId: entry.cardIds.at(-1), chunkCount: entry.cardIds.length,
3801
+ activeCardStart: 0, renderedCardContents: new Map(),
3758
3802
  answerStart: entry.answerStart ?? null, answerEnd: entry.answerEnd ?? null,
3759
3803
  deliveryViaOpenId: true,
3760
3804
  });
@@ -4266,6 +4310,8 @@ export class FeishuHarnessBridge {
4266
4310
  renderChain: null,
4267
4311
  chunkCount: 1,
4268
4312
  cardIds: [],
4313
+ activeCardStart: 0,
4314
+ renderedCardContents: new Map(),
4269
4315
  answerStart: null,
4270
4316
  answerEnd: null,
4271
4317
  // Answer-integrity tracking: the version bumps every time the draft
@@ -4416,62 +4462,51 @@ export class FeishuHarnessBridge {
4416
4462
  });
4417
4463
  }
4418
4464
 
4465
+ /** Sync only this interaction's cards; earlier interactions remain sealed. */
4466
+ async #syncStepCardChunks(card, chunks, status) {
4467
+ const answerVersion = card.answerVersion ?? 0;
4468
+ const activeIds = card.cardIds.slice(card.activeCardStart);
4469
+ const groups = chunks.length ? [...chunks] : [[]];
4470
+ // Retain the last message as the live card when a rewritten answer shrinks.
4471
+ // Clear stale prefixes without introducing a separate deletion/retry path.
4472
+ while (groups.length < activeIds.length) {
4473
+ groups.unshift([{ kind: 'message', text: t('内容已合并至后续消息。') }]);
4474
+ }
4475
+ // Serialize before the first await: a concurrent draft must not change the
4476
+ // payload halfway through this render or be marked as already delivered.
4477
+ const contents = groups.map((blocks, index) => stepStreamCard(blocks, {
4478
+ status: index === groups.length - 1 ? status : 'sealed',
4479
+ }));
4480
+ for (let index = 0; index < contents.length; index += 1) {
4481
+ let id = activeIds[index];
4482
+ const content = contents[index];
4483
+ if (id) {
4484
+ if (card.renderedCardContents.get(id) !== content) {
4485
+ await this.#patchStepCard(id, content);
4486
+ }
4487
+ } else {
4488
+ id = await this.#sendCard(card.chatId, content, card.deliveryViaOpenId
4489
+ ? { receiveIdType: 'open_id' }
4490
+ : { replyTo: card.replyToMessageId });
4491
+ card.cardIds.push(id);
4492
+ activeIds.push(id);
4493
+ }
4494
+ card.renderedCardContents.set(id, content);
4495
+ }
4496
+ card.messageId = activeIds.at(-1);
4497
+ card.chunkCount = activeIds.length;
4498
+ card.renderedAnswerVersion = answerVersion;
4499
+ }
4500
+
4419
4501
  async #renderStepCardNow(chatId, card) {
4420
4502
  if (card.broken) return;
4421
4503
  const chunks = splitStepStreamCardBlocks(card.blocks);
4504
+ if (!chunks.length && card.messageId === null) return;
4422
4505
  const live = chunks[chunks.length - 1] ?? [];
4423
4506
  try {
4424
- if (card.messageId === null) {
4425
- // First render: deliver every chunk. When the turn opens with a long
4426
- // answer already in memory, chunks.length can exceed one — sending
4427
- // only the live chunk here would silently drop its prefix, and the
4428
- // chunkCount bookkeeping below would then rewrite the delivered
4429
- // message with chunk 0 on the next render.
4430
- for (let index = 0; index < chunks.length; index += 1) {
4431
- const isLive = index === chunks.length - 1;
4432
- const id = await this.#sendCard(
4433
- chatId,
4434
- stepStreamCard(chunks[index], { status: isLive ? 'running' : 'sealed' }),
4435
- card.deliveryViaOpenId
4436
- ? { receiveIdType: 'open_id' }
4437
- : { replyTo: card.replyToMessageId },
4438
- );
4439
- card.cardIds.push(id);
4440
- if (isLive) card.messageId = id;
4441
- }
4442
- card.chunkCount = chunks.length;
4443
- await this.#persistMirrorState(card, live, 'running');
4444
- card.lastRenderAt = this.#stepPushClock.now();
4445
- card.renderedAnswerVersion = card.answerVersion ?? 0;
4446
- return;
4447
- }
4448
- // Overflow: the accumulated blocks outgrew one card. Seal the current
4449
- // message (no status line), spill extra chunks, and keep the last one
4450
- // live. Chunk boundaries are stable because blocks only append.
4451
- if (chunks.length > card.chunkCount) {
4452
- await this.#patchStepCard(
4453
- card.messageId,
4454
- stepStreamCard(chunks[card.chunkCount - 1], { status: 'sealed' }),
4455
- );
4456
- for (let index = card.chunkCount; index < chunks.length; index += 1) {
4457
- const isLive = index === chunks.length - 1;
4458
- const id = await this.#sendCard(
4459
- chatId,
4460
- stepStreamCard(chunks[index], { status: isLive ? 'running' : 'sealed' }),
4461
- card.deliveryViaOpenId
4462
- ? { receiveIdType: 'open_id' }
4463
- : { replyTo: card.replyToMessageId },
4464
- );
4465
- card.cardIds.push(id);
4466
- if (isLive) card.messageId = id;
4467
- }
4468
- card.chunkCount = chunks.length;
4469
- } else {
4470
- await this.#patchStepCard(card.messageId, stepStreamCard(live, { status: 'running' }));
4471
- }
4507
+ await this.#syncStepCardChunks(card, chunks, 'running');
4472
4508
  await this.#persistMirrorState(card, live, 'running');
4473
4509
  card.lastRenderAt = this.#stepPushClock.now();
4474
- card.renderedAnswerVersion = card.answerVersion ?? 0;
4475
4510
  } catch (error) {
4476
4511
  card.broken = true;
4477
4512
  this.#logger.warn?.(
@@ -4510,52 +4545,8 @@ export class FeishuHarnessBridge {
4510
4545
  this.#writeStepCardAnswer(card, body);
4511
4546
  }
4512
4547
  const chunks = splitStepStreamCardBlocks(card.blocks);
4513
- const live = chunks[chunks.length - 1] ?? [];
4514
4548
  try {
4515
- if (card.messageId === null) {
4516
- // A turn without any queued render (plain Q&A): open the cards
4517
- // directly with their terminal status; every overflow chunk before
4518
- // the last one is sealed. An empty turn still opens the status card.
4519
- const groups = chunks.length > 0 ? chunks : [[]];
4520
- for (let index = 0; index < groups.length; index += 1) {
4521
- const isLive = index === groups.length - 1;
4522
- const id = await this.#sendCard(
4523
- card.chatId,
4524
- stepStreamCard(groups[index], { status: isLive ? status : 'sealed' }),
4525
- card.deliveryViaOpenId
4526
- ? { receiveIdType: 'open_id' }
4527
- : { replyTo: card.replyToMessageId },
4528
- );
4529
- card.cardIds.push(id);
4530
- }
4531
- return { ok: true, cardIds: card.cardIds };
4532
- }
4533
- // The live message only ever shows the last chunk; spill every chunk
4534
- // that has not been delivered yet as sealed cards before patching the
4535
- // live one, otherwise a long answer finishing on an existing card
4536
- // would silently drop its prefix.
4537
- if (chunks.length > card.chunkCount) {
4538
- await this.#patchStepCard(
4539
- card.messageId,
4540
- stepStreamCard(chunks[card.chunkCount - 1], { status: 'sealed' }),
4541
- );
4542
- for (let index = card.chunkCount; index < chunks.length; index += 1) {
4543
- const isLast = index === chunks.length - 1;
4544
- const id = await this.#sendCard(
4545
- card.chatId,
4546
- stepStreamCard(chunks[index], { status: isLast ? status : 'sealed' }),
4547
- card.deliveryViaOpenId
4548
- ? { receiveIdType: 'open_id' }
4549
- : { replyTo: card.replyToMessageId },
4550
- );
4551
- card.cardIds.push(id);
4552
- if (isLast) card.messageId = id;
4553
- }
4554
- card.chunkCount = chunks.length;
4555
- } else {
4556
- await this.#patchStepCard(card.messageId, stepStreamCard(live, { status }));
4557
- }
4558
- card.renderedAnswerVersion = card.answerVersion ?? 0;
4549
+ await this.#syncStepCardChunks(card, chunks, status);
4559
4550
  return { ok: true, cardIds: card.cardIds };
4560
4551
  } catch (error) {
4561
4552
  // Only trust the streamed draft when the LAST SUCCESSFUL render actually
@@ -4606,8 +4597,7 @@ export class FeishuHarnessBridge {
4606
4597
  if (card.messageId === null) return;
4607
4598
  try {
4608
4599
  const chunks = splitStepStreamCardBlocks(card.blocks);
4609
- const live = chunks[chunks.length - 1] ?? [];
4610
- await this.#patchStepCard(card.messageId, stepStreamCard(live, { status: 'sealed' }));
4600
+ await this.#syncStepCardChunks(card, chunks, 'sealed');
4611
4601
  } catch (error) {
4612
4602
  this.#logger.warn?.(
4613
4603
  '[dsh-feishu] step streaming card rotate seal failed:',
@@ -4615,6 +4605,8 @@ export class FeishuHarnessBridge {
4615
4605
  );
4616
4606
  }
4617
4607
  // Restart the stream on a fresh card below the interaction message.
4608
+ card.activeCardStart = card.cardIds.length;
4609
+ card.renderedCardContents.clear();
4618
4610
  card.blocks = [];
4619
4611
  card.messageId = null;
4620
4612
  card.chunkCount = 1;
@@ -1009,20 +1009,191 @@ export function stepStatusText(status) {
1009
1009
  return t('运行中');
1010
1010
  }
1011
1011
 
1012
+ /**
1013
+ * A single card accepts only this many `table` elements. Feishu rejects the
1014
+ * whole write with `230099 / ErrCode 11310 card table number over limit`, which
1015
+ * leaves the card stuck on its previous content, so the reply never appears.
1016
+ *
1017
+ * The boundary was established by the maintainer against a live bot: five
1018
+ * tables in one card succeeded, six failed, and a folded process panel's tables
1019
+ * counted toward the total (3 folded + 2 in the body succeeded, 3 + 3 failed).
1020
+ */
1021
+ export const STEP_STREAM_CARD_MAX_TABLES = 5;
1022
+
1023
+ function hasClosingBacktickRun(text, start, length) {
1024
+ for (let index = start; index < text.length;) {
1025
+ if (text[index] !== '`') {
1026
+ index += 1;
1027
+ continue;
1028
+ }
1029
+ let end = index + 1;
1030
+ while (text[end] === '`') end += 1;
1031
+ if (end - index === length) return true;
1032
+ index = end;
1033
+ }
1034
+ return false;
1035
+ }
1036
+
1037
+ function gfmTableCells(line) {
1038
+ const normalized = line.endsWith('\r') ? line.slice(0, -1) : line;
1039
+ if (normalized.startsWith(' ') || normalized.startsWith('\t')) return null;
1040
+ const text = normalized.trim();
1041
+ if (!text) return null;
1042
+
1043
+ const cells = [];
1044
+ let cell = '';
1045
+ let separators = 0;
1046
+ let codeRun = 0;
1047
+ for (let index = 0; index < text.length;) {
1048
+ const character = text[index];
1049
+ if (character === '\\' && index + 1 < text.length) {
1050
+ cell += text.slice(index, index + 2);
1051
+ index += 2;
1052
+ continue;
1053
+ }
1054
+ if (character === '`') {
1055
+ let end = index + 1;
1056
+ while (text[end] === '`') end += 1;
1057
+ const length = end - index;
1058
+ if (codeRun === length) {
1059
+ codeRun = 0;
1060
+ } else if (codeRun === 0 && hasClosingBacktickRun(text, end, length)) {
1061
+ codeRun = length;
1062
+ }
1063
+ cell += text.slice(index, end);
1064
+ index = end;
1065
+ continue;
1066
+ }
1067
+ if (character === '|' && codeRun === 0) {
1068
+ cells.push(cell);
1069
+ cell = '';
1070
+ separators += 1;
1071
+ index += 1;
1072
+ continue;
1073
+ }
1074
+ cell += character;
1075
+ index += 1;
1076
+ }
1077
+ if (separators === 0) return null;
1078
+ cells.push(cell);
1079
+ if (cells[0].trim() === '') cells.shift();
1080
+ if (cells.at(-1)?.trim() === '') cells.pop();
1081
+ return cells.length > 0 ? cells : null;
1082
+ }
1083
+
1084
+ /** Locate table headers once for both counting and splitting. */
1085
+ function markdownTableStarts(text) {
1086
+ if (typeof text !== 'string' || !text) return [];
1087
+ const lines = text.split('\n');
1088
+ const offsets = [];
1089
+ let offset = 0;
1090
+ for (const line of lines) {
1091
+ offsets.push(offset);
1092
+ offset += line.length + 1;
1093
+ }
1094
+ const starts = [];
1095
+ let fence = null;
1096
+ for (let index = 0; index < lines.length; index += 1) {
1097
+ const line = lines[index].replace(/\r$/, '');
1098
+ if (fence) {
1099
+ const close = /^ {0,3}(`{3,}|~{3,})[ \t]*$/.exec(line);
1100
+ if (close && close[1][0] === fence[0] && close[1].length >= fence.length) fence = null;
1101
+ continue;
1102
+ }
1103
+ const opening = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line);
1104
+ if (opening && (opening[1][0] !== '`' || !opening[2].includes('`'))) {
1105
+ fence = opening[1];
1106
+ continue;
1107
+ }
1108
+ const header = gfmTableCells(line);
1109
+ const delimiter = gfmTableCells(lines[index + 1] ?? '');
1110
+ if (!header || !delimiter || header.length !== delimiter.length
1111
+ || !delimiter.every((cell) => /^\s*:?-+:?\s*$/.test(cell))) continue;
1112
+ starts.push(offsets[index]);
1113
+ index += 1;
1114
+ // A delimiter-looking data row is part of this table, not another header.
1115
+ while (index + 1 < lines.length && gfmTableCells(lines[index + 1])) index += 1;
1116
+ }
1117
+ return starts;
1118
+ }
1119
+
1120
+ export function countMarkdownTables(text) {
1121
+ return markdownTableStarts(text).length;
1122
+ }
1123
+
1124
+ function splitMarkdownByTableLimit(text, maxTables) {
1125
+ const starts = markdownTableStarts(text);
1126
+ const parts = [];
1127
+ let from = 0;
1128
+ for (let index = maxTables; index < starts.length; index += maxTables) {
1129
+ parts.push(text.slice(from, starts[index]));
1130
+ from = starts[index];
1131
+ }
1132
+ parts.push(text.slice(from));
1133
+ return parts;
1134
+ }
1135
+
1136
+ /** Count the Markdown that stepStreamCard actually renders. */
1137
+ function stepStreamCardTableCount(blocks) {
1138
+ return blocks.reduce((sum, block) => {
1139
+ const text = block?.kind === 'notes' || block?.kind === 'tools'
1140
+ ? (block.lines ?? []).filter((line) => typeof line === 'string' && line.trim()).join('\n')
1141
+ : block?.text;
1142
+ return sum + countMarkdownTables(text);
1143
+ }, 0);
1144
+ }
1145
+
1146
+ /** Split display copies; the bridge's logical blocks and answer indices stay intact. */
1147
+ export function splitBlockByTableLimit(block, maxTables = STEP_STREAM_CARD_MAX_TABLES) {
1148
+ if (!block || typeof block !== 'object' || !Number.isInteger(maxTables) || maxTables < 1) return [block];
1149
+ if (block.kind === 'notes' || block.kind === 'tools') {
1150
+ const lines = (block.lines ?? []).filter((line) => typeof line === 'string' && line.trim());
1151
+ if (countMarkdownTables(lines.join('\n')) <= maxTables) return [block];
1152
+ const groups = [];
1153
+ let current = [];
1154
+ for (const line of lines) {
1155
+ for (const part of splitMarkdownByTableLimit(line, maxTables)) {
1156
+ if (current.length && countMarkdownTables([...current, part].join('\n')) > maxTables) {
1157
+ groups.push(current);
1158
+ current = [];
1159
+ }
1160
+ current.push(part);
1161
+ }
1162
+ }
1163
+ if (current.length) groups.push(current);
1164
+ return groups.map((lines, index) => ({ ...block, lines, omitted: index === 0 ? block.omitted : 0 }));
1165
+ }
1166
+ if (typeof block.text !== 'string' || countMarkdownTables(block.text) <= maxTables) return [block];
1167
+ return splitMarkdownByTableLimit(block.text, maxTables).map((text) => ({ ...block, text }));
1168
+ }
1169
+
1012
1170
  /**
1013
1171
  * Split accumulated blocks into card-sized chunks at block boundaries,
1014
- * budgeted by the encoded running-status card (the largest render). Every
1015
- * chunk keeps at least one block so progress is never dropped. The caller
1016
- * renders all but the last chunk as `sealed` and the last one live.
1172
+ * budgeted by the encoded running-status card (the largest render) **and** by
1173
+ * the table count, because a card accepts only a handful of tables no matter
1174
+ * how small it is. Every chunk keeps at least one block so progress is never
1175
+ * dropped. The caller renders all but the last chunk as `sealed` and the last
1176
+ * one live.
1017
1177
  */
1018
- export function splitStepStreamCardBlocks(blocks, limit = STEP_STREAM_CARD_MAX_BYTES) {
1019
- const list = (Array.isArray(blocks) ? blocks : []).filter(Boolean);
1178
+ export function splitStepStreamCardBlocks(
1179
+ blocks,
1180
+ limit = STEP_STREAM_CARD_MAX_BYTES,
1181
+ maxTables = STEP_STREAM_CARD_MAX_TABLES,
1182
+ ) {
1183
+ // A single block can carry more tables than a card accepts — one short answer
1184
+ // with six small tables — and splitting only between blocks cannot help. Each
1185
+ // block is broken at table boundaries first, so the walk below sees pieces it
1186
+ // can actually distribute.
1187
+ const list = (Array.isArray(blocks) ? blocks : [])
1188
+ .filter(Boolean)
1189
+ .flatMap((block) => splitBlockByTableLimit(block, maxTables));
1020
1190
  if (list.length === 0) return [];
1021
1191
  const chunks = [];
1022
1192
  let current = [];
1023
1193
  for (const block of list) {
1024
- if (current.length > 0
1025
- && Buffer.byteLength(stepStreamCard([...current, block]), 'utf8') > limit) {
1194
+ const tooLarge = Buffer.byteLength(stepStreamCard([...current, block]), 'utf8') > limit;
1195
+ const tooManyTables = stepStreamCardTableCount([...current, block]) > maxTables;
1196
+ if (current.length > 0 && (tooLarge || tooManyTables)) {
1026
1197
  chunks.push(current);
1027
1198
  current = [block];
1028
1199
  } else {
@@ -534,8 +534,17 @@ export function extractInboundMessage(event, client) {
534
534
  const imageKeys = standaloneImageKey ? [standaloneImageKey] : post?.imageKeys ?? [];
535
535
  const file = messageType === 'file' ? feishuFileSource(event, client, parsed) : null;
536
536
  const replyTo = feishuReplyReference(event, client);
537
+ // A forwarded card arrives as `interactive`. Without this branch its text was
538
+ // dropped entirely, so the message looked empty and the reader got the
539
+ // "text, image and file only" notice — even though the same extraction is
540
+ // already trusted for quoted cards (`feishuReplyReference`).
541
+ const cardText = messageType === 'interactive' ? interactiveCardText(parsed) : '';
537
542
  return {
538
- content: messageType === 'text' ? extractText(event) ?? '' : post?.text ?? '',
543
+ content: messageType === 'text'
544
+ ? extractText(event) ?? ''
545
+ : messageType === 'interactive'
546
+ ? cardText
547
+ : post?.text ?? '',
539
548
  images: imageKeys.map((key) => feishuImageSource(event, client, key)),
540
549
  files: file ? [file] : [],
541
550
  ...(replyTo ? { replyTo } : {}),
@@ -1,5 +1,6 @@
1
1
  // English translations (feishu area). Keys are exact Chinese literals passed to t().
2
2
  export default {
3
+ '内容已合并至后续消息。': 'Content has been consolidated into the following messages.',
3
4
  // feishu/bridge.mjs — welcome / help
4
5
  '北汇星河 AIOS 已连接 DeepSeek Harness。':
5
6
  'BeiHui XingHe AIOS is connected to DeepSeek Harness.',