@xmanrui/dsh-im 4.24.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.
@@ -113,7 +113,7 @@ function processAlive(pid) {
113
113
  function publicJob(job) {
114
114
  if (!job) return null;
115
115
  const { id, state, targetVersion, message } = job;
116
- return { id, state, targetVersion, message };
116
+ return { id, state, targetVersion, message, recoverable: job.recoverable === true };
117
117
  }
118
118
 
119
119
  /** One update job per profile. Persist intent before starting pnpm, and never apply a restart here. */
@@ -158,27 +158,32 @@ export function createUpdateService({
158
158
  if (!JOB_STATES.has(job.state) || typeof job.id !== 'string' || !semver.valid(job.targetVersion)) {
159
159
  throw updateError('state-unavailable');
160
160
  }
161
- if (ACTIVE_STATES.has(job.state) && job.id !== activeJob?.id) {
161
+ // Recovery is derived from the current installation, never a persisted flag.
162
+ job = { ...job, recoverable: false };
163
+ const historical = job.id !== activeJob?.id;
164
+ if (ACTIVE_STATES.has(job.state) && historical) {
162
165
  if (lock === NO_LOCK || lock?.id !== job.id || !processAlive(lock?.pid)) {
163
166
  job = { ...job, state: 'interrupted', message: 'recovery-required' };
164
167
  }
165
168
  }
166
- if (job.id !== activeJob?.id && !ACTIVE_STATES.has(job.state)) {
167
- if (lock !== NO_LOCK) return { ...job, state: 'interrupted', message: 'recovery-required' };
168
- // A later Host can retry after a verified manual repair. Never infer this
169
- // from version equality while an old process lock is still present.
170
- if (['failed', 'interrupted'].includes(job.state) && environment.packageValid === true
171
- && environment.installedVersion === runningVersion && !environment.blockedReason) {
172
- return job.targetVersion === runningVersion
173
- ? { ...job, state: 'completed', message: 'recovered' }
174
- : { ...job, recoverable: true };
175
- }
169
+ if (historical && !ACTIVE_STATES.has(job.state) && lock !== NO_LOCK) {
170
+ return { ...job, state: 'interrupted', message: 'recovery-required' };
176
171
  }
177
172
  if (job.state === 'restart-required' || job.state === 'completed') {
178
173
  if (environment.installedVersion !== job.targetVersion || environment.packageValid !== true) {
179
- return { ...job, state: 'interrupted', message: 'installation-changed' };
174
+ job = { ...job, state: 'interrupted', message: 'installation-changed' };
175
+ } else {
176
+ job = { ...job, state: runningVersion === job.targetVersion ? 'completed' : 'restart-required' };
180
177
  }
181
- return { ...job, state: runningVersion === job.targetVersion ? 'completed' : 'restart-required' };
178
+ }
179
+ // Reconcile after deriving the job state so an external upgrade or rollback
180
+ // can also recover. Keep history read-only and do not claim its target ran.
181
+ if (historical && lock === NO_LOCK && ['failed', 'interrupted'].includes(job.state)
182
+ && environment.packageValid === true && environment.eligible === true
183
+ && environment.installedVersion === runningVersion && !environment.blockedReason) {
184
+ return job.targetVersion === runningVersion
185
+ ? { ...job, state: 'completed', message: 'recovered' }
186
+ : { ...job, recoverable: true };
182
187
  }
183
188
  return job;
184
189
  }
@@ -124,6 +124,7 @@ const RESOLVED_REPLY_TTL_MS = 30 * 60_000;
124
124
  const ROOT_CANDIDATE_TTL_MS = 30 * 60_000;
125
125
  /** Bound main-feed roots awaiting a topic so silent drops cannot grow them. */
126
126
  const MAX_ROOT_CANDIDATES = 512;
127
+ const MAX_TOPIC_ANCHORS = 4_000;
127
128
 
128
129
  const MENU_COMMAND = /^\/m(?:enu)?$/i;
129
130
  const REPAIR_COMMAND_PREFIX = /^\/repair(?:\s|$)/i;
@@ -624,6 +625,8 @@ export class FeishuHarnessBridge {
624
625
  #anchorTopicReply = new Map();
625
626
  /** Main-feed roots this bot intends to auto-open as topics, awaiting thread_id. */
626
627
  #rootCandidates = new Map();
628
+ /** Last successfully injected topic root per conversation and AI session. */
629
+ #topicAnchors = new Map();
627
630
  #repair;
628
631
  #repairAttempt = null;
629
632
  #repairMonitorVersion = 0;
@@ -4875,6 +4878,72 @@ export class FeishuHarnessBridge {
4875
4878
  };
4876
4879
  }
4877
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
+
4878
4947
  /**
4879
4948
  * 分步直推:`#answerWithStream` 流式分支在开关开启时的完整替代路径。
4880
4949
  * 过程(工具调用、助手中间说明)与最终答案均以富文本 post 逐条直推
@@ -4886,30 +4955,18 @@ export class FeishuHarnessBridge {
4886
4955
  const messageId = event.message.message_id;
4887
4956
  const text = message.content;
4888
4957
  let askCompleted = false;
4889
- const markAskComplete = () => {
4958
+ const markAskComplete = (sessionId) => {
4890
4959
  if (askCompleted) return;
4891
4960
  askCompleted = true;
4961
+ prompt.onAskComplete?.(sessionId);
4892
4962
  this.#endImTurn(key);
4893
4963
  onAskComplete?.();
4894
4964
  };
4895
4965
  // 与流式分支一致的提示内容构造:图片与回复引用展开为富提示内容,已接受
4896
4966
  // 的上下文增强按原样重放。直推分流发生在 `#answerWithStream` 构造之前,
4897
4967
  // 这里就是本回合唯一一次构造(无重复的 prompt 往返)。
4898
- let content = hasInboundImages(message) || hasReplyReference(message)
4899
- ? await promptContentForInboundMessage(message, { signal: this.#signal, deferImages: true })
4900
- : undefined;
4901
- const snapshot = this.#acceptedMessageIds.get(messageId);
4902
- let contextEnhanced = false;
4903
- if (snapshot) {
4904
- const originalContent = content ?? text;
4905
- content = enhanceContextContent(originalContent, snapshot, () => ({
4906
- channel: 'feishu',
4907
- senderId: senderOpenId(event),
4908
- chatId: event.message.chat_id,
4909
- threadId: event.message.thread_id,
4910
- }));
4911
- contextEnhanced = content !== originalContent;
4912
- }
4968
+ const prompt = await this.#preparePrompt(event, key, message);
4969
+ const { content, contextEnhanced } = prompt;
4913
4970
  // Every turn reopens the per-conversation circuit breaker.
4914
4971
  const priorState = this.#stepPushSendState.get(key);
4915
4972
  if (this.#stepPushSendState.size >= MAX_STEP_PUSH_SEND_STATES && !priorState) {
@@ -5010,7 +5067,8 @@ export class FeishuHarnessBridge {
5010
5067
  text,
5011
5068
  content,
5012
5069
  titleText: event.batchSubmission?.title,
5013
- sourceGuidance: snapshot?.config?.guidance,
5070
+ sourceGuidance: prompt.sourceGuidance,
5071
+ prepareContent: prompt.prepareContent,
5014
5072
  contextEnhanced,
5015
5073
  createOptions: { signal: this.#signal },
5016
5074
  existsOptions: { signal: this.#signal },
@@ -5089,8 +5147,8 @@ export class FeishuHarnessBridge {
5089
5147
  // 回合结束(成功/失败/中断):停看门狗并撤回残留思考中心跳。
5090
5148
  await watchdog?.stop();
5091
5149
  }
5150
+ markAskComplete(completed.sessionId);
5092
5151
  await cot?.finish();
5093
- markAskComplete();
5094
5152
  const finalStepText = pendingStep ? pendingStep.text : null;
5095
5153
  pendingStep = null;
5096
5154
  const finalText = (typeof finalStepText === 'string' && finalStepText.trim())
@@ -5228,9 +5286,10 @@ export class FeishuHarnessBridge {
5228
5286
  const messageId = event.message.message_id;
5229
5287
  const text = message.content;
5230
5288
  let askCompleted = false;
5231
- const markAskComplete = () => {
5289
+ const markAskComplete = (sessionId) => {
5232
5290
  if (askCompleted) return;
5233
5291
  askCompleted = true;
5292
+ prompt.onAskComplete?.(sessionId);
5234
5293
  this.#endImTurn(key);
5235
5294
  onAskComplete?.();
5236
5295
  };
@@ -5241,23 +5300,10 @@ export class FeishuHarnessBridge {
5241
5300
  if (this.#stepPush && this.#channel?.stream) {
5242
5301
  return this.#answerWithStepPush(event, key, message, { onAskComplete });
5243
5302
  }
5244
- let content = hasInboundImages(message) || hasReplyReference(message)
5245
- ? await promptContentForInboundMessage(message, { signal: this.#signal, deferImages: true })
5246
- : undefined;
5247
- const snapshot = this.#acceptedMessageIds.get(messageId);
5248
- let contextEnhanced = false;
5249
- if (snapshot) {
5250
- const originalContent = content ?? text;
5251
- content = enhanceContextContent(originalContent, snapshot, () => ({
5252
- channel: 'feishu',
5253
- senderId: senderOpenId(event),
5254
- chatId: event.message.chat_id,
5255
- threadId: event.message.thread_id,
5256
- }));
5257
- contextEnhanced = content !== originalContent;
5258
- }
5303
+ const prompt = await this.#preparePrompt(event, key, message);
5304
+ const { content, contextEnhanced } = prompt;
5259
5305
  if (!this.#channel?.stream) {
5260
- const { answer, artifacts = [] } = await askInWorkspaceSession({
5306
+ const { sessionId, answer, artifacts = [] } = await askInWorkspaceSession({
5261
5307
  deferredDelivery: () => ({ coordinator: this.#deferred, chatId, replyToMessageId: messageId }),
5262
5308
  harness: this.#harness,
5263
5309
  state: this.#state,
@@ -5265,13 +5311,14 @@ export class FeishuHarnessBridge {
5265
5311
  text,
5266
5312
  content,
5267
5313
  titleText: event.batchSubmission?.title,
5268
- sourceGuidance: snapshot?.config?.guidance,
5314
+ sourceGuidance: prompt.sourceGuidance,
5315
+ prepareContent: prompt.prepareContent,
5269
5316
  contextEnhanced,
5270
5317
  createOptions: { signal: this.#signal },
5271
5318
  existsOptions: { signal: this.#signal },
5272
5319
  askOptions: this.#interactionAskOptions(event, key, message.files, message.images),
5273
5320
  });
5274
- markAskComplete();
5321
+ markAskComplete(sessionId);
5275
5322
  let textReceipt;
5276
5323
  let textSendError = null;
5277
5324
  try {
@@ -5346,13 +5393,14 @@ export class FeishuHarnessBridge {
5346
5393
  text,
5347
5394
  content,
5348
5395
  titleText: event.batchSubmission?.title,
5349
- sourceGuidance: snapshot?.config?.guidance,
5396
+ sourceGuidance: prompt.sourceGuidance,
5397
+ prepareContent: prompt.prepareContent,
5350
5398
  contextEnhanced,
5351
5399
  createOptions: { signal: this.#signal },
5352
5400
  existsOptions: { signal: this.#signal },
5353
5401
  askOptions,
5354
5402
  });
5355
- markAskComplete();
5403
+ markAskComplete(completed.sessionId);
5356
5404
  completedAnswer = completed.answer;
5357
5405
  completedArtifacts = completed.artifacts ?? [];
5358
5406
  await controller.setContent(answerTextForDelivery(completedAnswer, completedArtifacts));
@@ -5410,7 +5458,7 @@ export class FeishuHarnessBridge {
5410
5458
  if (promptStarted) throw error;
5411
5459
 
5412
5460
  this.#logger.warn?.('[dsh-feishu] native stream unavailable; using text fallback:', error.message);
5413
- const { answer, artifacts = [] } = await askInWorkspaceSession({
5461
+ const { sessionId, answer, artifacts = [] } = await askInWorkspaceSession({
5414
5462
  deferredDelivery: () => ({ coordinator: this.#deferred, chatId, replyToMessageId: messageId }),
5415
5463
  harness: this.#harness,
5416
5464
  state: this.#state,
@@ -5418,13 +5466,14 @@ export class FeishuHarnessBridge {
5418
5466
  text,
5419
5467
  content,
5420
5468
  titleText: event.batchSubmission?.title,
5421
- sourceGuidance: snapshot?.config?.guidance,
5469
+ sourceGuidance: prompt.sourceGuidance,
5470
+ prepareContent: prompt.prepareContent,
5422
5471
  contextEnhanced,
5423
5472
  createOptions: { signal: this.#signal },
5424
5473
  existsOptions: { signal: this.#signal },
5425
5474
  askOptions: this.#interactionAskOptions(event, key, message.files, message.images),
5426
5475
  });
5427
- markAskComplete();
5476
+ markAskComplete(sessionId);
5428
5477
  let textReceipt;
5429
5478
  let textSendError = null;
5430
5479
  try {
@@ -94,6 +94,7 @@ export async function askInWorkspaceSession({
94
94
  key,
95
95
  text,
96
96
  content,
97
+ prepareContent,
97
98
  titleText,
98
99
  sourceGuidance,
99
100
  contextEnhanced = false,
@@ -165,8 +166,13 @@ export async function askInWorkspaceSession({
165
166
  await originalOnArtifact?.(artifact);
166
167
  };
167
168
  let answer;
169
+ // Prepare session-dependent context after binding, outside the binding
170
+ // lock. A stale-workspace retry must prepare it for the new session too.
171
+ const prompt = prepareContent
172
+ ? await prepareContent({ sessionId: binding.sessionId, content, text })
173
+ : content ?? text;
168
174
  try {
169
- answer = await binding.session.ask(content ?? text, artifactOptions);
175
+ answer = await binding.session.ask(prompt, artifactOptions);
170
176
  } catch (error) {
171
177
  if (error?.code === 'harness-reply-timeout' && deferredDelivery) {
172
178
  try {