@xmanrui/dsh-im 4.23.0 → 4.24.1

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.
Files changed (41) hide show
  1. package/README.en.md +7 -2
  2. package/README.md +7 -2
  3. package/lib/client.js +498 -347
  4. package/lib/index.js +284 -283
  5. package/package.json +11 -2
  6. package/plugin-src/client/channel-card-meta.js +3 -9
  7. package/plugin-src/client/channels/dingtalk/index.js +9 -8
  8. package/plugin-src/client/channels/feishu/index.js +21 -17
  9. package/plugin-src/client/channels/qq/index.js +9 -8
  10. package/plugin-src/client/channels/shared/collapsible-account.js +49 -26
  11. package/plugin-src/client/channels/shared/token-api.js +3 -0
  12. package/plugin-src/client/channels/shared/token-channel.js +9 -8
  13. package/plugin-src/client/channels/telegram/index.js +3 -0
  14. package/plugin-src/client/channels/telegram/styles.js +12 -0
  15. package/plugin-src/client/channels/telegram/thinking-traces.js +25 -0
  16. package/plugin-src/client/channels/wecom/index.js +9 -8
  17. package/plugin-src/client/channels/wecom-app/index.js +9 -8
  18. package/plugin-src/client/channels/weixin/index.js +9 -8
  19. package/plugin-src/client/channels/whatsapp/index.js +9 -8
  20. package/plugin-src/client/i18n.js +13 -1
  21. package/plugin-src/client/index.js +10 -2
  22. package/plugin-src/client/styles.js +12 -8
  23. package/plugin-src/client/update-panel.js +18 -8
  24. package/plugin-src/host/channels/shared/rpc.mjs +9 -0
  25. package/plugin-src/host/channels/shared/thinking-traces-rpc.mjs +11 -0
  26. package/plugin-src/host/modern-harness-api.mjs +7 -2
  27. package/plugin-src/host/update-service.mjs +19 -14
  28. package/scripts/verify-package.mjs +11 -5
  29. package/src/channels/email/email-runtime.mjs +9 -2
  30. package/src/channels/email/transports/agent-mail.mjs +14 -3
  31. package/src/channels/feishu/bridge.mjs +122 -46
  32. package/src/channels/feishu/feishu-channel.mjs +35 -0
  33. package/src/channels/feishu/live-cot.mjs +260 -0
  34. package/src/channels/feishu/slash-command-registry.mjs +17 -0
  35. package/src/channels/feishu/step-push-mode.mjs +10 -4
  36. package/src/channels/shared/harness-client.mjs +224 -39
  37. package/src/channels/shared/text-harness-bridge.mjs +60 -14
  38. package/src/channels/shared/workspace-session.mjs +7 -1
  39. package/src/channels/telegram/config-store.mjs +4 -1
  40. package/src/channels/telegram/telegram-controller.mjs +13 -1
  41. package/src/channels/telegram/telegram-runtime.mjs +198 -1
@@ -115,6 +115,7 @@ import {
115
115
  FEISHU_STEP_PUSH_MODES,
116
116
  normalizeFeishuStepPushMode,
117
117
  } from './step-push-mode.mjs';
118
+ import { FeishuLiveCot } from './live-cot.mjs';
118
119
 
119
120
  // Lazily evaluated: t() must run after setImHostLanguage, not at import time.
120
121
  const INTERACTION_RESOLVED_TEXT = () => t('这个问题已在其他客户端处理,无需再次回答。');
@@ -123,6 +124,7 @@ const RESOLVED_REPLY_TTL_MS = 30 * 60_000;
123
124
  const ROOT_CANDIDATE_TTL_MS = 30 * 60_000;
124
125
  /** Bound main-feed roots awaiting a topic so silent drops cannot grow them. */
125
126
  const MAX_ROOT_CANDIDATES = 512;
127
+ const MAX_TOPIC_ANCHORS = 4_000;
126
128
 
127
129
  const MENU_COMMAND = /^\/m(?:enu)?$/i;
128
130
  const REPAIR_COMMAND_PREFIX = /^\/repair(?:\s|$)/i;
@@ -607,7 +609,7 @@ export class FeishuHarnessBridge {
607
609
  #groupTopicReply = false;
608
610
  /** When true, streaming turns push tool calls and interim notes as discrete messages. */
609
611
  #stepPush = false;
610
- /** Step push presentation: 'post' (discrete messages) or 'streaming_card'. */
612
+ /** Step push presentation: discrete posts, a CardKit card, or native live CoT. */
611
613
  #stepPushMode = FEISHU_STEP_PUSH_MODES.POST;
612
614
  /** Per-conversation step push state: key → { lastSentAt, count, breakerLogged }. */
613
615
  #stepPushSendState = new Map();
@@ -623,6 +625,8 @@ export class FeishuHarnessBridge {
623
625
  #anchorTopicReply = new Map();
624
626
  /** Main-feed roots this bot intends to auto-open as topics, awaiting thread_id. */
625
627
  #rootCandidates = new Map();
628
+ /** Last successfully injected topic root per conversation and AI session. */
629
+ #topicAnchors = new Map();
626
630
  #repair;
627
631
  #repairAttempt = null;
628
632
  #repairMonitorVersion = 0;
@@ -4874,6 +4878,72 @@ export class FeishuHarnessBridge {
4874
4878
  };
4875
4879
  }
4876
4880
 
4881
+ async #preparePrompt(event, key, message) {
4882
+ const snapshot = this.#acceptedMessageIds.get(event.message.message_id);
4883
+ const build = async (input) => {
4884
+ const rich = hasInboundImages(input) || hasReplyReference(input);
4885
+ const original = rich
4886
+ ? await promptContentForInboundMessage(input, { signal: this.#signal, deferImages: true })
4887
+ : input.content;
4888
+ const enhanced = enhanceContextContent(original, snapshot, () => ({
4889
+ channel: 'feishu',
4890
+ senderId: senderOpenId(event),
4891
+ chatId: event.message.chat_id,
4892
+ threadId: event.message.thread_id,
4893
+ }));
4894
+ return {
4895
+ content: rich || enhanced !== original ? enhanced : undefined,
4896
+ contextEnhanced: enhanced !== original,
4897
+ sourceGuidance: snapshot?.config?.guidance,
4898
+ };
4899
+ };
4900
+ const rootId = nonEmptyString(event.message.root_id);
4901
+ const topicAnchor = event.message.chat_type === 'group'
4902
+ && nonEmptyString(event.message.thread_id)
4903
+ && rootId && message.replyTo?.messageId === rootId
4904
+ // A reference-only message must not become an empty prompt.
4905
+ && (nonEmptyString(message.content) || hasInboundImages(message) || hasInboundFiles(message));
4906
+ if (!topicAnchor) return build(message);
4907
+
4908
+ const withoutAnchor = { ...message };
4909
+ delete withoutAnchor.replyTo;
4910
+ const base = await build(withoutAnchor);
4911
+ let preparedSessionId;
4912
+ let anchorLoaded = false;
4913
+ return {
4914
+ ...base,
4915
+ prepareContent: async ({ sessionId }) => {
4916
+ preparedSessionId = sessionId;
4917
+ anchorLoaded = false;
4918
+ const previous = this.#topicAnchors.get(key);
4919
+ if (previous?.sessionId === sessionId && previous.rootMessageId === rootId) {
4920
+ return base.content ?? message.content;
4921
+ }
4922
+ const prepared = await build({
4923
+ ...message,
4924
+ replyTo: {
4925
+ ...message.replyTo,
4926
+ load: async (options) => {
4927
+ const reference = await message.replyTo.load(options);
4928
+ anchorLoaded = !reference?.unavailableReason && Boolean(
4929
+ nonEmptyString(reference?.content) || reference?.attachments?.length,
4930
+ );
4931
+ return reference;
4932
+ },
4933
+ },
4934
+ });
4935
+ return prepared.content;
4936
+ },
4937
+ onAskComplete: (sessionId) => {
4938
+ if (!anchorLoaded || sessionId !== preparedSessionId) return;
4939
+ if (!this.#topicAnchors.has(key) && this.#topicAnchors.size >= MAX_TOPIC_ANCHORS) {
4940
+ this.#topicAnchors.delete(this.#topicAnchors.keys().next().value);
4941
+ }
4942
+ this.#topicAnchors.set(key, { sessionId, rootMessageId: rootId });
4943
+ },
4944
+ };
4945
+ }
4946
+
4877
4947
  /**
4878
4948
  * 分步直推:`#answerWithStream` 流式分支在开关开启时的完整替代路径。
4879
4949
  * 过程(工具调用、助手中间说明)与最终答案均以富文本 post 逐条直推
@@ -4885,30 +4955,18 @@ export class FeishuHarnessBridge {
4885
4955
  const messageId = event.message.message_id;
4886
4956
  const text = message.content;
4887
4957
  let askCompleted = false;
4888
- const markAskComplete = () => {
4958
+ const markAskComplete = (sessionId) => {
4889
4959
  if (askCompleted) return;
4890
4960
  askCompleted = true;
4961
+ prompt.onAskComplete?.(sessionId);
4891
4962
  this.#endImTurn(key);
4892
4963
  onAskComplete?.();
4893
4964
  };
4894
4965
  // 与流式分支一致的提示内容构造:图片与回复引用展开为富提示内容,已接受
4895
4966
  // 的上下文增强按原样重放。直推分流发生在 `#answerWithStream` 构造之前,
4896
4967
  // 这里就是本回合唯一一次构造(无重复的 prompt 往返)。
4897
- let content = hasInboundImages(message) || hasReplyReference(message)
4898
- ? await promptContentForInboundMessage(message, { signal: this.#signal, deferImages: true })
4899
- : undefined;
4900
- const snapshot = this.#acceptedMessageIds.get(messageId);
4901
- let contextEnhanced = false;
4902
- if (snapshot) {
4903
- const originalContent = content ?? text;
4904
- content = enhanceContextContent(originalContent, snapshot, () => ({
4905
- channel: 'feishu',
4906
- senderId: senderOpenId(event),
4907
- chatId: event.message.chat_id,
4908
- threadId: event.message.thread_id,
4909
- }));
4910
- contextEnhanced = content !== originalContent;
4911
- }
4968
+ const prompt = await this.#preparePrompt(event, key, message);
4969
+ const { content, contextEnhanced } = prompt;
4912
4970
  // Every turn reopens the per-conversation circuit breaker.
4913
4971
  const priorState = this.#stepPushSendState.get(key);
4914
4972
  if (this.#stepPushSendState.size >= MAX_STEP_PUSH_SEND_STATES && !priorState) {
@@ -4931,6 +4989,19 @@ export class FeishuHarnessBridge {
4931
4989
  // 流式卡片模式:每轮一张过程卡(原地 patch),过程与最终答案都进卡;
4932
4990
  // post 模式维持逐条直推。先预建卡片状态,纯问答回合也能在收尾时开卡。
4933
4991
  const streamingCard = this.#stepPushMode === FEISHU_STEP_PUSH_MODES.STREAMING_CARD;
4992
+ const liveCot = this.#stepPushMode === FEISHU_STEP_PUSH_MODES.LIVE_COT
4993
+ && typeof this.#channel?.createCot === 'function'
4994
+ && typeof this.#channel?.writeCotEvents === 'function';
4995
+ const cot = liveCot
4996
+ ? new FeishuLiveCot(this.#channel, chatId, {
4997
+ replyTo: messageId,
4998
+ hidden: false,
4999
+ onFailure: (error) => this.#logger.warn?.(
5000
+ '[dsh-feishu] native live process failed; final answer will continue:',
5001
+ error?.message ?? String(error),
5002
+ ),
5003
+ })
5004
+ : null;
4934
5005
  if (streamingCard) {
4935
5006
  if (this.#stepCards.has(key)) {
4936
5007
  // 上一轮异常退出留下的「运行中」卡片先收尾,避免与本轮混淆。
@@ -4983,7 +5054,9 @@ export class FeishuHarnessBridge {
4983
5054
  messageId,
4984
5055
  );
4985
5056
  };
4986
- const watchdog = streamingCard ? null : this.#startThinkingStatusWatchdog(key, chatId, messageId);
5057
+ const watchdog = streamingCard || liveCot
5058
+ ? null
5059
+ : this.#startThinkingStatusWatchdog(key, chatId, messageId);
4987
5060
  let completed;
4988
5061
  try {
4989
5062
  completed = await askInWorkspaceSession({
@@ -4994,13 +5067,14 @@ export class FeishuHarnessBridge {
4994
5067
  text,
4995
5068
  content,
4996
5069
  titleText: event.batchSubmission?.title,
4997
- sourceGuidance: snapshot?.config?.guidance,
5070
+ sourceGuidance: prompt.sourceGuidance,
5071
+ prepareContent: prompt.prepareContent,
4998
5072
  contextEnhanced,
4999
5073
  createOptions: { signal: this.#signal },
5000
5074
  existsOptions: { signal: this.#signal },
5001
5075
  askOptions: {
5002
5076
  ...baseAskOptions,
5003
- progressMode: 'all',
5077
+ progressMode: liveCot ? 'live' : 'all',
5004
5078
  // 提问/审批卡弹出前先定格当前过程卡,答案随之流到交互消息之后
5005
5079
  // 的新卡上(与主流式路径的 rotate 语义一致)。
5006
5080
  ...(streamingCard ? {
@@ -5012,6 +5086,15 @@ export class FeishuHarnessBridge {
5012
5086
  },
5013
5087
  } : {}),
5014
5088
  onUpdate: async (update) => {
5089
+ if (liveCot) {
5090
+ await cot.handle(update);
5091
+ if (update.type === 'assistant-message') {
5092
+ pendingStep = update;
5093
+ } else if (update.type === 'tool') {
5094
+ pendingStep = null;
5095
+ }
5096
+ return;
5097
+ }
5015
5098
  if (update.type === 'assistant-message') {
5016
5099
  if (pendingStep && Number(update.step) > Number(pendingStep.step)) {
5017
5100
  await flushPendingStep();
@@ -5054,6 +5137,7 @@ export class FeishuHarnessBridge {
5054
5137
  },
5055
5138
  });
5056
5139
  } catch (error) {
5140
+ await cot?.finish(error);
5057
5141
  if (streamingCard) {
5058
5142
  this.#stepStopFlags.delete(key);
5059
5143
  await this.#finishStepCard(key, { stopped: true });
@@ -5063,7 +5147,8 @@ export class FeishuHarnessBridge {
5063
5147
  // 回合结束(成功/失败/中断):停看门狗并撤回残留思考中心跳。
5064
5148
  await watchdog?.stop();
5065
5149
  }
5066
- markAskComplete();
5150
+ markAskComplete(completed.sessionId);
5151
+ await cot?.finish();
5067
5152
  const finalStepText = pendingStep ? pendingStep.text : null;
5068
5153
  pendingStep = null;
5069
5154
  const finalText = (typeof finalStepText === 'string' && finalStepText.trim())
@@ -5130,7 +5215,7 @@ export class FeishuHarnessBridge {
5130
5215
  }
5131
5216
  textReceipt = createDeliveryReceipt({
5132
5217
  deliveryId: messageId,
5133
- presentation: 'feishu-step-push-post',
5218
+ presentation: liveCot ? 'feishu-live-cot-answer' : 'feishu-step-push-post',
5134
5219
  providerMessageIds,
5135
5220
  });
5136
5221
  this.#status.streamResponses = (this.#status.streamResponses ?? 0) + 1;
@@ -5201,9 +5286,10 @@ export class FeishuHarnessBridge {
5201
5286
  const messageId = event.message.message_id;
5202
5287
  const text = message.content;
5203
5288
  let askCompleted = false;
5204
- const markAskComplete = () => {
5289
+ const markAskComplete = (sessionId) => {
5205
5290
  if (askCompleted) return;
5206
5291
  askCompleted = true;
5292
+ prompt.onAskComplete?.(sessionId);
5207
5293
  this.#endImTurn(key);
5208
5294
  onAskComplete?.();
5209
5295
  };
@@ -5214,23 +5300,10 @@ export class FeishuHarnessBridge {
5214
5300
  if (this.#stepPush && this.#channel?.stream) {
5215
5301
  return this.#answerWithStepPush(event, key, message, { onAskComplete });
5216
5302
  }
5217
- let content = hasInboundImages(message) || hasReplyReference(message)
5218
- ? await promptContentForInboundMessage(message, { signal: this.#signal, deferImages: true })
5219
- : undefined;
5220
- const snapshot = this.#acceptedMessageIds.get(messageId);
5221
- let contextEnhanced = false;
5222
- if (snapshot) {
5223
- const originalContent = content ?? text;
5224
- content = enhanceContextContent(originalContent, snapshot, () => ({
5225
- channel: 'feishu',
5226
- senderId: senderOpenId(event),
5227
- chatId: event.message.chat_id,
5228
- threadId: event.message.thread_id,
5229
- }));
5230
- contextEnhanced = content !== originalContent;
5231
- }
5303
+ const prompt = await this.#preparePrompt(event, key, message);
5304
+ const { content, contextEnhanced } = prompt;
5232
5305
  if (!this.#channel?.stream) {
5233
- const { answer, artifacts = [] } = await askInWorkspaceSession({
5306
+ const { sessionId, answer, artifacts = [] } = await askInWorkspaceSession({
5234
5307
  deferredDelivery: () => ({ coordinator: this.#deferred, chatId, replyToMessageId: messageId }),
5235
5308
  harness: this.#harness,
5236
5309
  state: this.#state,
@@ -5238,13 +5311,14 @@ export class FeishuHarnessBridge {
5238
5311
  text,
5239
5312
  content,
5240
5313
  titleText: event.batchSubmission?.title,
5241
- sourceGuidance: snapshot?.config?.guidance,
5314
+ sourceGuidance: prompt.sourceGuidance,
5315
+ prepareContent: prompt.prepareContent,
5242
5316
  contextEnhanced,
5243
5317
  createOptions: { signal: this.#signal },
5244
5318
  existsOptions: { signal: this.#signal },
5245
5319
  askOptions: this.#interactionAskOptions(event, key, message.files, message.images),
5246
5320
  });
5247
- markAskComplete();
5321
+ markAskComplete(sessionId);
5248
5322
  let textReceipt;
5249
5323
  let textSendError = null;
5250
5324
  try {
@@ -5319,13 +5393,14 @@ export class FeishuHarnessBridge {
5319
5393
  text,
5320
5394
  content,
5321
5395
  titleText: event.batchSubmission?.title,
5322
- sourceGuidance: snapshot?.config?.guidance,
5396
+ sourceGuidance: prompt.sourceGuidance,
5397
+ prepareContent: prompt.prepareContent,
5323
5398
  contextEnhanced,
5324
5399
  createOptions: { signal: this.#signal },
5325
5400
  existsOptions: { signal: this.#signal },
5326
5401
  askOptions,
5327
5402
  });
5328
- markAskComplete();
5403
+ markAskComplete(completed.sessionId);
5329
5404
  completedAnswer = completed.answer;
5330
5405
  completedArtifacts = completed.artifacts ?? [];
5331
5406
  await controller.setContent(answerTextForDelivery(completedAnswer, completedArtifacts));
@@ -5383,7 +5458,7 @@ export class FeishuHarnessBridge {
5383
5458
  if (promptStarted) throw error;
5384
5459
 
5385
5460
  this.#logger.warn?.('[dsh-feishu] native stream unavailable; using text fallback:', error.message);
5386
- const { answer, artifacts = [] } = await askInWorkspaceSession({
5461
+ const { sessionId, answer, artifacts = [] } = await askInWorkspaceSession({
5387
5462
  deferredDelivery: () => ({ coordinator: this.#deferred, chatId, replyToMessageId: messageId }),
5388
5463
  harness: this.#harness,
5389
5464
  state: this.#state,
@@ -5391,13 +5466,14 @@ export class FeishuHarnessBridge {
5391
5466
  text,
5392
5467
  content,
5393
5468
  titleText: event.batchSubmission?.title,
5394
- sourceGuidance: snapshot?.config?.guidance,
5469
+ sourceGuidance: prompt.sourceGuidance,
5470
+ prepareContent: prompt.prepareContent,
5395
5471
  contextEnhanced,
5396
5472
  createOptions: { signal: this.#signal },
5397
5473
  existsOptions: { signal: this.#signal },
5398
5474
  askOptions: this.#interactionAskOptions(event, key, message.files, message.images),
5399
5475
  });
5400
- markAskComplete();
5476
+ markAskComplete(sessionId);
5401
5477
  let textReceipt;
5402
5478
  let textSendError = null;
5403
5479
  try {
@@ -8,6 +8,7 @@ const STREAM_ELEMENT_ID = 'stream_md';
8
8
  const DEFAULT_INITIAL_TEXT = '已连接 DeepSeek Harness,正在思考…';
9
9
  const MAX_STREAM_CHARS = 28000;
10
10
  const MAX_FILE_OPERATION_TIMEOUT_MS = 120_000;
11
+ const COT_API = '/open-apis/im/v1/message_cot';
11
12
 
12
13
  const FILE_DELIVERY_ERRORS = new Map([
13
14
  [99991672, ['artifact-permission-required', 'Feishu file delivery requires the im:resource permission.']],
@@ -365,6 +366,40 @@ export class VerifiedFeishuChannel {
365
366
  });
366
367
  }
367
368
 
369
+ /** Open one native Feishu thinking-process message. */
370
+ async createCot(chatId, { replyTo, hidden = false } = {}) {
371
+ const response = assertApiSuccess('Feishu message_cot.create', await this.#client.request({
372
+ method: 'POST',
373
+ url: `${COT_API}?receive_id_type=chat_id`,
374
+ data: {
375
+ receive_id: chatId,
376
+ ...(replyTo ? { origin_message_id: replyTo } : {}),
377
+ cot_hidden: hidden === true,
378
+ enable_badge: false,
379
+ update_feed_rank: false,
380
+ },
381
+ }));
382
+ const cotId = response?.data?.cot_id;
383
+ const messageId = response?.data?.message_id;
384
+ if (!cotId || !messageId) {
385
+ throw new Error('Feishu message_cot.create returned no cot_id/message_id');
386
+ }
387
+ return { cotId, messageId };
388
+ }
389
+
390
+ /** Append ordered AG-UI events to one native thinking process. */
391
+ async writeCotEvents(handle, events) {
392
+ assertApiSuccess('Feishu message_cot.write', await this.#client.request({
393
+ method: 'PUT',
394
+ url: COT_API,
395
+ data: {
396
+ events,
397
+ message_id: handle.messageId,
398
+ cot_id: handle.cotId,
399
+ },
400
+ }));
401
+ }
402
+
368
403
  async #sendArtifact(chatId, file, {
369
404
  replyTo,
370
405
  signal,
@@ -0,0 +1,260 @@
1
+ const MAX_EVENTS_PER_WRITE = 50;
2
+ const MAX_EVENT_CONTENT_CHARS = 4096;
3
+ const MAX_TOOL_RESULT_CHARS = 1500;
4
+
5
+ const TOOL_ICONS = Object.freeze({
6
+ read: 'read',
7
+ edit: 'write',
8
+ delete: 'write',
9
+ move: 'write',
10
+ search: 'search',
11
+ fetch: 'search',
12
+ execute: 'bash',
13
+ });
14
+
15
+ let lastTimestamp = 0;
16
+
17
+ function boundedEventContent(content) {
18
+ const encoded = JSON.stringify(content);
19
+ if (encoded.length <= MAX_EVENT_CONTENT_CHARS) return encoded;
20
+ const field = typeof content?.delta === 'string'
21
+ ? 'delta'
22
+ : typeof content?.message === 'string' ? 'message' : null;
23
+ if (!field) return JSON.stringify({ ...content, truncated: true });
24
+
25
+ const truncated = { ...content, truncated: true, [field]: content[field] };
26
+ while (truncated[field].length > 0
27
+ && JSON.stringify(truncated).length > MAX_EVENT_CONTENT_CHARS) {
28
+ truncated[field] = truncated[field].slice(0, Math.max(0, truncated[field].length - 256));
29
+ }
30
+ if (truncated[field].length === 0
31
+ && JSON.stringify(truncated).length > MAX_EVENT_CONTENT_CHARS) {
32
+ return JSON.stringify({ truncated: true });
33
+ }
34
+ return JSON.stringify(truncated);
35
+ }
36
+
37
+ function cotEvent(eventType, content) {
38
+ lastTimestamp = Math.max(Date.now(), lastTimestamp + 1);
39
+ return {
40
+ event_type: eventType,
41
+ content: boundedEventContent(content),
42
+ timestamp: String(lastTimestamp),
43
+ };
44
+ }
45
+
46
+ function boundedResult(value) {
47
+ const text = String(value ?? '');
48
+ return text.length <= MAX_TOOL_RESULT_CHARS
49
+ ? text
50
+ : `${text.slice(0, MAX_TOOL_RESULT_CHARS - 1)}…`;
51
+ }
52
+
53
+ function toolKind(name) {
54
+ const value = String(name ?? '').toLowerCase();
55
+ if (/read|view|list|get/.test(value)) return 'read';
56
+ if (/edit|write|create|patch|update/.test(value)) return 'edit';
57
+ if (/delete|remove/.test(value)) return 'delete';
58
+ if (/move|rename/.test(value)) return 'move';
59
+ if (/search|grep|glob|find/.test(value)) return 'search';
60
+ if (/fetch|web|http|download/.test(value)) return 'fetch';
61
+ if (/shell|bash|command|exec|terminal/.test(value)) return 'execute';
62
+ return '';
63
+ }
64
+
65
+ function turnFailure(reason) {
66
+ const kind = typeof reason === 'string' ? reason : reason?.kind;
67
+ if (reason === null || reason === undefined || kind === 'completed') return null;
68
+ if (kind !== 'error') {
69
+ return kind ? `Turn ended: ${kind}` : 'Turn failed';
70
+ }
71
+ const error = reason.error ?? reason.failure;
72
+ if (!error) return 'Turn failed';
73
+ return [error.code, error.message].filter(Boolean).join(': ') || 'Turn failed';
74
+ }
75
+
76
+ /**
77
+ * Maps Harness progress updates to Feishu's native thinking-process events.
78
+ * Process failures are contained here so they can never suppress the answer.
79
+ */
80
+ export class FeishuLiveCot {
81
+ #channel;
82
+ #chatId;
83
+ #replyTo;
84
+ #hidden;
85
+ #onFailure;
86
+ #turn = null;
87
+ #opening = null;
88
+ #chain = Promise.resolve();
89
+ #pending = [];
90
+ #reasoningOpen = false;
91
+ #heldAssistant = null;
92
+ #finished = false;
93
+ #broken = false;
94
+ #messageIndex = 0;
95
+
96
+ constructor(channel, chatId, {
97
+ replyTo,
98
+ hidden = false,
99
+ onFailure = () => {},
100
+ } = {}) {
101
+ this.#channel = channel;
102
+ this.#chatId = chatId;
103
+ this.#replyTo = replyTo;
104
+ this.#hidden = hidden === true;
105
+ this.#onFailure = onFailure;
106
+ }
107
+
108
+ async #start(turn) {
109
+ if (this.#turn !== null || this.#broken) return;
110
+ this.#turn = Number.isSafeInteger(turn) ? turn : 0;
111
+ this.#opening = this.#channel.createCot(this.#chatId, {
112
+ ...(this.#replyTo ? { replyTo: this.#replyTo } : {}),
113
+ hidden: this.#hidden,
114
+ }).catch((error) => {
115
+ this.#broken = true;
116
+ this.#onFailure(error);
117
+ return null;
118
+ });
119
+ await this.#write([
120
+ cotEvent('RUN_STARTED', {
121
+ threadId: this.#chatId,
122
+ runId: `turn-${this.#turn}`,
123
+ }),
124
+ ]);
125
+ }
126
+
127
+ #write(events) {
128
+ if (this.#broken || events.length === 0) return;
129
+ this.#pending.push(...events);
130
+ this.#chain = this.#chain.then(async () => {
131
+ const handle = await this.#opening;
132
+ if (!handle || this.#broken) {
133
+ this.#pending.length = 0;
134
+ return;
135
+ }
136
+ while (this.#pending.length > 0) {
137
+ await this.#channel.writeCotEvents(
138
+ handle,
139
+ this.#pending.splice(0, MAX_EVENTS_PER_WRITE),
140
+ );
141
+ }
142
+ }).catch((error) => {
143
+ this.#broken = true;
144
+ this.#pending.length = 0;
145
+ this.#onFailure(error);
146
+ });
147
+ }
148
+
149
+ async #closeReasoning() {
150
+ if (!this.#reasoningOpen) return;
151
+ this.#reasoningOpen = false;
152
+ await this.#write([
153
+ cotEvent('REASONING_MESSAGE_END', {
154
+ messageId: `reasoning-${this.#turn}`,
155
+ }),
156
+ ]);
157
+ }
158
+
159
+ async #flushHeldAssistant() {
160
+ const held = this.#heldAssistant;
161
+ this.#heldAssistant = null;
162
+ if (!held?.text) return;
163
+ const messageId = `text-${this.#turn}-${this.#messageIndex++}`;
164
+ await this.#write([
165
+ cotEvent('TEXT_MESSAGE_START', { messageId, role: 'assistant' }),
166
+ cotEvent('TEXT_MESSAGE_CONTENT', { messageId, delta: held.text }),
167
+ cotEvent('TEXT_MESSAGE_END', { messageId }),
168
+ ]);
169
+ }
170
+
171
+ async handle(update) {
172
+ if (!update || this.#finished || this.#broken) return;
173
+ if (update.type === 'turn-start') {
174
+ await this.#start(update.turn);
175
+ return;
176
+ }
177
+ await this.#start(update.turn);
178
+ if (update.type === 'reasoning') {
179
+ if (!update.text) return;
180
+ const messageId = `reasoning-${this.#turn}`;
181
+ if (!this.#reasoningOpen) {
182
+ this.#reasoningOpen = true;
183
+ await this.#write([
184
+ cotEvent('REASONING_MESSAGE_START', { messageId, role: 'reasoning' }),
185
+ ]);
186
+ }
187
+ await this.#write([
188
+ cotEvent('REASONING_MESSAGE_CONTENT', { messageId, delta: update.text }),
189
+ ]);
190
+ return;
191
+ }
192
+ if (update.type === 'assistant-message') {
193
+ if (this.#heldAssistant) await this.#flushHeldAssistant();
194
+ this.#heldAssistant = update;
195
+ return;
196
+ }
197
+ if (update.type === 'tool') {
198
+ await this.#flushHeldAssistant();
199
+ await this.#closeReasoning();
200
+ const toolCallId = update.callId ?? `tool-${this.#turn}-${this.#messageIndex++}`;
201
+ const kind = toolKind(update.name);
202
+ await this.#write([
203
+ cotEvent('TOOL_CALL_START', {
204
+ toolCallId,
205
+ icon: TOOL_ICONS[kind] ?? 'default',
206
+ title: update.name || 'Tool',
207
+ toolCallName: update.name || 'tool',
208
+ }),
209
+ cotEvent('TOOL_CALL_ARGS', {
210
+ toolCallId,
211
+ delta: update.arguments ?? '',
212
+ }),
213
+ cotEvent('TOOL_CALL_END', { toolCallId }),
214
+ ]);
215
+ return;
216
+ }
217
+ if (update.type === 'tool-result' && update.callId) {
218
+ await this.#write([
219
+ cotEvent('TOOL_CALL_RESULT', {
220
+ messageId: `result-${update.callId}`,
221
+ toolCallId: update.callId,
222
+ role: 'tool',
223
+ content: { type: 'code', code: boundedResult(update.text) },
224
+ ...(update.errorCode ? { error: update.errorCode } : {}),
225
+ }),
226
+ ]);
227
+ return;
228
+ }
229
+ if (update.type === 'turn-end') {
230
+ await this.finish(update.reason);
231
+ }
232
+ }
233
+
234
+ async finish(reason) {
235
+ if (this.#finished) {
236
+ await this.#chain;
237
+ return;
238
+ }
239
+ this.#finished = true;
240
+ this.#heldAssistant = null;
241
+ if (this.#turn === null || this.#broken) {
242
+ await this.#chain;
243
+ return;
244
+ }
245
+ await this.#closeReasoning();
246
+ const failure = reason instanceof Error
247
+ ? reason.message
248
+ : turnFailure(reason);
249
+ await this.#write([
250
+ failure
251
+ ? cotEvent('RUN_ERROR', { message: failure, code: 'TURN_FAILED' })
252
+ : cotEvent('RUN_FINISHED', {
253
+ threadId: this.#chatId,
254
+ runId: `turn-${this.#turn}`,
255
+ status: 'done',
256
+ }),
257
+ ]);
258
+ await this.#chain;
259
+ }
260
+ }
@@ -50,6 +50,23 @@ export const SLASH_COMMAND_MANIFEST = Object.freeze([
50
50
  { command: 'unwatch', icon: 'clear_outlined', default: '取消关注会话', en_us: 'Unwatch a session' },
51
51
  { command: 'watchlist', icon: 'flag_outlined', default: '查看关注列表', en_us: 'List watched sessions' },
52
52
  { command: 'archived', icon: 'folder_outlined', default: '设置归档会话显隐(on/off)', en_us: 'Show or hide archived sessions (on/off)' },
53
+ { command: 'history', icon: 'chat-ai_outlined', default: '查看最近历史消息(仅私聊)', en_us: 'Show recent history (private chats only)' },
54
+ { command: 'workspace', icon: 'folder_outlined', default: '切换工作区', en_us: 'Switch workspace' },
55
+ { command: 'conv', icon: 'folder_outlined', default: '设置当前对话专属工作区', en_us: 'Set the workspace for this conversation' },
56
+ { command: 'session', icon: 'chat-ai_outlined', default: '绑定已有会话', en_us: 'Bind an existing session' },
57
+ { command: 'models', icon: 'ai-functions_outlined', default: '列出可用模型', en_us: 'List available models' },
58
+ { command: 'model', icon: 'ai-agent_outlined', default: '查看或切换当前模型', en_us: 'Show or switch the current model' },
59
+ { command: 'reasoninglist', icon: 'ai-deepthink_outlined', default: '列出可用推理等级', en_us: 'List available reasoning efforts' },
60
+ { command: 'reasoning', icon: 'ai-deepthink_outlined', default: '查看或切换推理等级', en_us: 'Show or switch the reasoning effort' },
61
+ { command: 'presetlist', icon: 'skill_outlined', default: '列出可用 Agent 预设', en_us: 'List available Agent Presets' },
62
+ { command: 'preset', icon: 'skill_outlined', default: '查看或切换 Agent 预设', en_us: 'Show or switch the Agent Preset' },
63
+ { command: 'stop', icon: 'clear_outlined', default: '停止当前任务', en_us: 'Stop the current task' },
64
+ { command: 'steer', icon: 'promptword_outlined', default: '给当前任务补充指令', en_us: 'Send additional instructions to the current task' },
65
+ { command: 'batch', icon: 'chat-ai_outlined', default: '开始批量输入(仅私聊)', en_us: 'Start batch input (private chats only)' },
66
+ { command: 'send', icon: 'chat-ai_outlined', default: '提交当前批次(仅私聊)', en_us: 'Submit the current batch (private chats only)' },
67
+ { command: 'cancel', icon: 'clear_outlined', default: '取消当前批次(仅私聊)', en_us: 'Cancel the current batch (private chats only)' },
68
+ { command: 'version', icon: 'ai-functions_outlined', default: '查看插件版本', en_us: 'Show the plugin version' },
69
+ { command: 'repair', icon: 'ai-functions_outlined', default: '补全飞书权限与卡片回调(仅私聊)', en_us: 'Complete Feishu permissions and card callbacks (private chats only)' },
53
70
  ]);
54
71
 
55
72
  // Commands that require a parameter are registered too, so the user can type