dsh-lark-agent 0.3.3 → 0.5.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.
Files changed (51) hide show
  1. package/README.md +10 -5
  2. package/dist/bot-service.js +4 -2
  3. package/dist/bridge.d.ts +27 -1
  4. package/dist/bridge.js +333 -66
  5. package/dist/card-actions.js +3 -1
  6. package/dist/client/AgentSettings.d.ts +12 -0
  7. package/dist/client/ConnectionCard.d.ts +5 -1
  8. package/dist/client/ManagerPanel.d.ts +3 -0
  9. package/dist/client/api.d.ts +14 -2
  10. package/dist/client/index.d.ts +1 -1
  11. package/dist/client/manager-styles.d.ts +2 -0
  12. package/dist/client/use-connection.d.ts +2 -2
  13. package/dist/client.cjs +727 -258
  14. package/dist/config.d.ts +4 -0
  15. package/dist/config.js +8 -0
  16. package/dist/dsh-runtime.d.ts +3 -0
  17. package/dist/dsh-runtime.js +46 -4
  18. package/dist/lark-cli.d.ts +20 -1
  19. package/dist/lark-cli.js +168 -2
  20. package/dist/media-input.d.ts +28 -0
  21. package/dist/media-input.js +127 -0
  22. package/dist/media-scopes.d.ts +5 -0
  23. package/dist/media-scopes.js +6 -0
  24. package/dist/message.d.ts +7 -0
  25. package/dist/message.js +27 -3
  26. package/dist/onboarding/agents.d.ts +9 -0
  27. package/dist/onboarding/agents.js +100 -0
  28. package/dist/onboarding/controller.d.ts +15 -2
  29. package/dist/onboarding/controller.js +155 -14
  30. package/dist/onboarding/host.d.ts +14 -2
  31. package/dist/onboarding/host.js +49 -32
  32. package/dist/onboarding/lark-platform.d.ts +6 -0
  33. package/dist/onboarding/lark-platform.js +15 -4
  34. package/dist/onboarding/manager.d.ts +32 -0
  35. package/dist/onboarding/manager.js +129 -0
  36. package/dist/onboarding/model-check.d.ts +5 -0
  37. package/dist/onboarding/model-check.js +51 -0
  38. package/dist/onboarding/protocol.d.ts +51 -1
  39. package/dist/onboarding/settings-store.d.ts +11 -0
  40. package/dist/onboarding/settings-store.js +67 -0
  41. package/dist/replies.d.ts +16 -0
  42. package/dist/replies.js +37 -11
  43. package/dist/session-controls.d.ts +34 -0
  44. package/dist/session-controls.js +66 -0
  45. package/dist/session-store.d.ts +7 -1
  46. package/dist/session-store.js +44 -11
  47. package/dist/topic-cards.d.ts +3 -0
  48. package/dist/topic-cards.js +8 -0
  49. package/dist/topic-store.d.ts +40 -0
  50. package/dist/topic-store.js +192 -0
  51. package/package.json +1 -1
package/dist/bridge.js CHANGED
@@ -15,8 +15,14 @@ import { PresentationError, presentationDraft, presentationCard, presentationTex
15
15
  import { LiveReply } from './live-reply.js';
16
16
  import { answeredText, approvalCard, questionAnswers, questionCard, simpleQuestion } from './interaction-cards.js';
17
17
  import { permissionPanel, permissionStateText } from './permission-controls.js';
18
+ import { TopicStoreError } from './topic-store.js';
19
+ import { sessionControlPanel, sessionPage, sessionQuery, sessionViewCommandParse } from './session-controls.js';
20
+ import { MediaInputError, prepareMedia } from './media-input.js';
18
21
  class ArtifactDeliveryError extends Error {
19
22
  }
23
+ /** A creation may have reached Lark. Never send another message as a fallback. */
24
+ class TopicDeliveryError extends Error {
25
+ }
20
26
  const APPROVAL_TIMEOUT_MS = 5 * 60_000;
21
27
  const QUESTION_TIMEOUT_MS = 10 * 60_000;
22
28
  // After a live card cannot be created (for example, before the CardKit scope is
@@ -27,7 +33,7 @@ const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
27
33
  const TIMEOUT_TEXT = '本次处理超时,已请求取消。请缩小问题范围后重试。';
28
34
  const STOPPING_TEXT = '连接正在关闭,本次处理已停止。';
29
35
  // Clicks on these panels update the clicked card itself; anything else answers with a new message.
30
- const IN_PLACE = /^\/(?:(?:model|thinking)(?: (?:use [a-f0-9]{16}|page [0-9]{1,4}))?|permission(?: use [a-z][a-z0-9-]{0,39})?|sessions|new|resume [a-f0-9]{12})$/;
36
+ const IN_PLACE = /^\/(?:(?:model|thinking)(?: (?:use [a-f0-9]{16}|page [0-9]{1,4}))?|permission(?: use [a-z][a-z0-9-]{0,39})?|sessions(?: page [1-9][0-9]?(?: [A-Za-z0-9_-]{1,428})?)?|new|resume [a-f0-9]{12})$/;
31
37
  // A switch that finishes sooner goes straight to its result, without a loading frame.
32
38
  const PANEL_PROGRESS_DELAY_MS = 150;
33
39
  const STALE_SETTINGS = '设置刚刚变化,已刷新为最新选项,请重新选择。';
@@ -37,7 +43,7 @@ class StaleCardError extends Error {
37
43
  }
38
44
  function commandError(error) {
39
45
  return error instanceof DshRuntimeError || error instanceof MemoryStoreError || error instanceof SessionStoreError
40
- || error instanceof ModelPreferencesError || error instanceof StaleCardError
46
+ || error instanceof ModelPreferencesError || error instanceof StaleCardError || error instanceof TopicStoreError || error instanceof TopicDeliveryError
41
47
  ? error.message : '操作未完成,请稍后重试;尚未确认保存成功。';
42
48
  }
43
49
  /** Only a regular file inside the user's own workspace is uploaded; anything else is just named. */
@@ -73,15 +79,18 @@ const HELP = `直接把想做的事告诉我就好。
73
79
 
74
80
  比如:帮我理清一个问题、整理一段文字,或者接着刚才的话题继续。你也可以在私聊告诉我,以后希望我怎样配合你。
75
81
 
76
- 私聊底部菜单可以新建话题、找历史对话,或调整模型和思考深度。
82
+ 私聊底部菜单可以新建话题、搜索和选择历史对话,或调整模型和思考深度。
83
+ 多个机器人与 Agent 可在电脑的「飞书 agent」设置中分别管理。
84
+ 私聊和群聊的新问题都会开一个飞书话题;新话题卡片下方点「回复话题」继续,群聊中仍需 @我。
77
85
  也可以发 /new 换话题、/sessions 找对话、/model 看模型、/thinking 调深度、/stop 停下来。`;
78
86
  const HARNESS_HELP = `
79
87
 
80
- 这里连着你电脑上的 Harness:可以让我查资料、读网页、处理工作区里的文件,回复会实时显示,思考过程折叠在卡片里;需要你确认的操作,我会先发卡片问你。发 /permission(或点底部菜单 设置 → 权限模式)可以切换权限,比如改成不再询问的「完全权限」。模型、思考深度和权限模式与电脑上的 Harness 同步。`;
88
+ 这里连着你电脑上的 Harness:可以让我查资料、读网页、处理工作区里的文件,回复会实时显示,思考过程折叠在卡片里;需要你确认的操作,我会先发卡片问你。发 /permission(或点底部菜单 设置 → 权限模式)可以切换权限,比如改成不再询问的「完全权限」。模型、思考深度和权限模式与电脑上的 Harness 同步。
89
+ 可直接发图片(需要宿主与模型支持图片输入);在电脑设置中启用语音后,可发送 60 秒以内语音,识别成文字继续对话。`;
81
90
  const ADVANCED_HELP = `高级命令(按需使用):
82
- /new 或 /reset:归档当前会话,下一条消息开启新会话;长期记忆保留
83
- /sessions:选择以前的对话;/sessions detail 查看完整列表和 ID
84
- /resume <ID>:恢复列表中的会话
91
+ /new 或 /reset:创建新的飞书话题,在新话题里开始;旧话题和长期记忆保留
92
+ /sessions:搜索、翻页并选择以前的对话;/sessions detail 查看 ID,/sessions page <页码> 查看其他页
93
+ /resume <ID>:打开列表中会话对应的话题,旧会话首次使用时补建话题入口
85
94
  /rename <标题>:给当前会话命名
86
95
  /status:看看我是否在线;/status detail 查看会话和记忆诊断
87
96
  /stop:停止当前处理并取消排队消息
@@ -149,6 +158,8 @@ export class BotBridge {
149
158
  progress;
150
159
  interactions = new Map();
151
160
  liveUnavailableUntil = 0;
161
+ topicCreations = new Map();
162
+ topicResumes = new Map();
152
163
  constructor(config, runtime, transport, ledger, state, audit = () => undefined, progressTransport, artifactPublisher) {
153
164
  this.config = config;
154
165
  this.runtime = runtime;
@@ -167,7 +178,7 @@ export class BotBridge {
167
178
  if (!this.config.cardInteractions || !this.state.actions || !raw || typeof raw !== 'object' || Array.isArray(raw))
168
179
  return;
169
180
  const data = raw;
170
- if (data.type !== 'card.action.trigger' || data.action_tag !== 'button'
181
+ if (data.type !== 'card.action.trigger' || (data.action_tag !== 'button' && data.action_tag !== 'select_static')
171
182
  || typeof data.event_id !== 'string' || !/^[a-zA-Z0-9_-]{1,128}$/.test(data.event_id)
172
183
  || typeof data.message_id !== 'string' || !/^om_[a-zA-Z0-9_-]+$/.test(data.message_id)
173
184
  || typeof data.action_value !== 'string' || data.action_value.length > 4000)
@@ -187,6 +198,39 @@ export class BotBridge {
187
198
  this.audit({ stage: 'card-action-rejected' });
188
199
  return;
189
200
  }
201
+ let command = binding.command;
202
+ let sessionView;
203
+ const browser = sessionViewCommandParse(command);
204
+ if (data.action_tag === 'select_static') {
205
+ // A selector can only navigate this owner's catalog, never invoke another signed capability.
206
+ if (browser?.kind !== 'pick' || data.action_name !== 'dsh_session_picker'
207
+ || typeof data.option !== 'string' || !/^[a-f0-9]{12}$/.test(data.option))
208
+ return;
209
+ sessionView = browser.view;
210
+ command = `/resume ${data.option}`;
211
+ }
212
+ else if (browser?.kind === 'pick')
213
+ return;
214
+ else if (browser)
215
+ sessionView = browser.view;
216
+ else if (command === '/sessions search') {
217
+ if (data.action_name !== 'dsh_sessions_search' || typeof data.form_value !== 'string' || data.form_value.length > 1000)
218
+ return;
219
+ let fields;
220
+ try {
221
+ fields = JSON.parse(data.form_value);
222
+ }
223
+ catch {
224
+ return;
225
+ }
226
+ if (!fields || typeof fields !== 'object' || Array.isArray(fields) || Object.keys(fields).length !== 1 || !('query' in fields))
227
+ return;
228
+ const query = sessionQuery(fields.query);
229
+ if (query === undefined)
230
+ return;
231
+ sessionView = { page: 0, query };
232
+ command = '/sessions';
233
+ }
190
234
  let formValues;
191
235
  if (binding.command.startsWith('/answer ') || binding.command.startsWith('/reply ')) {
192
236
  if (data.action_name !== 'dsh_submit' || typeof data.form_value !== 'string' || data.form_value.length > 8000)
@@ -200,12 +244,12 @@ export class BotBridge {
200
244
  if (!formValues || typeof formValues !== 'object' || Array.isArray(formValues))
201
245
  return;
202
246
  }
203
- const mutation = /^\/(?:(?:new|resume|forget|answer)(?: |$)|(?:model|thinking|permission) use )/.test(binding.command);
247
+ const mutation = /^\/(?:(?:new|resume|forget|answer)(?: |$)|(?:model|thinking|permission) use )/.test(command);
204
248
  return { messageId: data.message_id, senderId: binding.ownerId, chatId: binding.chatId,
205
- chatType: binding.chatType, threadId: binding.threadId, text: binding.command,
249
+ chatType: binding.chatType, threadId: binding.threadId, text: command,
206
250
  requestId: `card:${data.event_id}`, actionNonce: mutation ? binding.nonce : undefined,
207
251
  expectedSessionId: binding.expectedSessionId, expectedModelRevision: binding.expectedModelRevision,
208
- ...(formValues === undefined ? {} : { formValues }) };
252
+ ...(formValues === undefined ? {} : { formValues }), ...(sessionView ? { sessionView } : {}) };
209
253
  }
210
254
  accept(raw) {
211
255
  const admission = this.acceptMessage(raw);
@@ -270,7 +314,26 @@ export class BotBridge {
270
314
  await this.answerInteraction(message);
271
315
  return;
272
316
  }
273
- const key = sessionKey(message, this.config);
317
+ if (this.topicsEnabled() && message.threadId) {
318
+ // A thread event can arrive as soon as the opening reply is visible.
319
+ // Wait for its binding, not the model turn that follows it.
320
+ message.topic = await this.state.topics.get(this.topicScope(message), message.threadId);
321
+ if (!message.topic) {
322
+ await Promise.all(this.topicCreations.get(this.topicScope(message).chatKey) ?? []);
323
+ if (this.stopped)
324
+ return;
325
+ message.topic = await this.state.topics.get(this.topicScope(message), message.threadId);
326
+ }
327
+ message.foreignTopic = !message.topic && await this.state.topics.occupied(this.topicScope(message).chatKey, message.threadId);
328
+ }
329
+ // Recognized settings questions use the entry lane before any provisional topic context is allocated.
330
+ if (!message.resources?.length && this.state.modelPreferences && this.runtime.catalog && isModelQuestion(message.text))
331
+ message.text = '/model current';
332
+ const startsTopic = this.topicsEnabled() && !message.threadId && (!message.text.startsWith('/') || Boolean(message.resources?.length));
333
+ const key = message.topic?.laneKey ?? (startsTopic ? this.newTopicKey(message) : sessionKey(message, this.config));
334
+ if (startsTopic)
335
+ message.conversationKey = key;
336
+ message.laneKey = key;
274
337
  let lane = this.lanes.get(key);
275
338
  if (message.text === '/stop') {
276
339
  if (lane) {
@@ -319,6 +382,11 @@ export class BotBridge {
319
382
  current.pending--;
320
383
  this.pending--;
321
384
  current.lastUsed = Date.now();
385
+ // Entry commands create no native session. Do not let their empty lane
386
+ // take the last slot needed by the topic they just opened.
387
+ if (this.topicsEnabled() && !current.pending && !current.session && !current.saved && !current.unavailable
388
+ && this.lanes.get(key) === current)
389
+ this.lanes.delete(key);
322
390
  });
323
391
  current.tail = job;
324
392
  // Release ingress before waiting for generation, while pending remains bounded.
@@ -333,6 +401,12 @@ export class BotBridge {
333
401
  }
334
402
  async process(message, lane) {
335
403
  const generation = lane.generation;
404
+ if (this.topicsEnabled() && message.threadId && !message.foreignTopic) {
405
+ // The first turn may have published its session while this message queued.
406
+ message.topic = await this.state.topics.get(this.topicScope(message), message.threadId);
407
+ if (!message.topic)
408
+ message.foreignTopic = await this.state.topics.occupied(this.topicScope(message).chatKey, message.threadId);
409
+ }
336
410
  if (message.chatType === 'group') {
337
411
  message.groupAccessKey = await this.state.memories.groupAccessKey(this.context(message));
338
412
  if (lane.groupAccessKey !== message.groupAccessKey) {
@@ -342,9 +416,19 @@ export class BotBridge {
342
416
  lane.groupAccessKey = message.groupAccessKey;
343
417
  }
344
418
  }
345
- if (this.state.modelPreferences && this.runtime.catalog && isModelQuestion(message.text))
346
- message.text = '/model current';
419
+ if (this.topicsEnabled() && (message.foreignTopic || (message.topic && message.topic.groupAccessKey !== message.groupAccessKey))) {
420
+ const fresh = /^\/(?:new|reset|clear)$/i.test(message.text) && message.expectedSessionId === undefined;
421
+ if (!fresh)
422
+ return this.reply(message, message.foreignTopic
423
+ ? '这个话题已有独立的会话。请在主聊天中 @我,或发送 /new 开始你自己的话题。'
424
+ : '记忆授权已变化,这个话题的旧上下文已停用。请发送 /new 开始新话题。');
425
+ message.topic = undefined;
426
+ message.conversationKey = this.entryKey(message);
427
+ }
347
428
  let text = message.text;
429
+ if (message.resources?.length && (message.chatType !== 'p2p' || lane.user.directMode !== 'harness')) {
430
+ return this.reply(message, '图片和语音输入仅用于已验证本人的 Harness 私聊,请改发文字。');
431
+ }
348
432
  const inPlace = this.inPlace(message);
349
433
  // A stale card is redrawn with the current state instead of answered with another message.
350
434
  if (message.expectedSessionId !== undefined) {
@@ -405,19 +489,26 @@ export class BotBridge {
405
489
  return this.reply(message, error instanceof PresentationError ? error.message : '作品暂时无法读取,已有内容会保留。');
406
490
  }
407
491
  }
408
- if (text.startsWith('/')) {
492
+ if (text.startsWith('/') && !message.resources?.length) {
409
493
  if (inPlace)
410
494
  return this.panelInPlace(message, lane);
411
- const feedback = this.commandFeedback(message);
495
+ const feedback = this.topicsEnabled() && /^\/(?:new|reset|clear|resume)(?:\s|$)/i.test(text)
496
+ ? undefined : this.commandFeedback(message);
412
497
  let response;
413
498
  let panel;
414
499
  let failed = false;
415
500
  try {
416
501
  response = await this.command(message, lane);
502
+ // A newly posted entry card already confirms the operation and owns its thread.
503
+ // A clicked private picker is handled above and still updates its existing card.
504
+ if (message.topicResult?.newEntry)
505
+ return;
417
506
  if (this.config.responseMode !== 'text' && (message.menu ? this.transport.sendRichUser : this.transport.replyRich))
418
507
  panel = await this.commandPanel(message, lane);
419
508
  }
420
509
  catch (error) {
510
+ if (error instanceof TopicDeliveryError)
511
+ throw error;
421
512
  failed = true;
422
513
  response = commandError(error);
423
514
  this.audit({ stage: 'command-failed', messageId: message.messageId });
@@ -429,6 +520,24 @@ export class BotBridge {
429
520
  }
430
521
  if (text.length > this.config.maxInputChars)
431
522
  return this.reply(message, '消息过长,请缩短后重试。');
523
+ if (this.stopped || lane.generation !== generation)
524
+ return;
525
+ if (this.topicsEnabled()) {
526
+ if (!message.threadId) {
527
+ const binding = await this.createTopic(message, preview(text, 40) || '新话题', message.conversationKey);
528
+ message.requestId ??= message.messageId;
529
+ message.messageId = binding.messageId;
530
+ message.threadId = binding.threadId;
531
+ message.topic = binding;
532
+ }
533
+ else if (!message.topic) {
534
+ // A user may start from a native topic that already exists in Lark.
535
+ const saved = await this.state.sessions.current(this.sessionContext(message));
536
+ message.topic = await this.state.topics.record({ ...this.topicScope(message), threadId: message.threadId,
537
+ messageId: message.messageId, laneKey: message.laneKey, conversationKey: this.context(message).conversationKey,
538
+ ...(saved ? { sessionId: saved.id } : {}), ...(message.groupAccessKey ? { groupAccessKey: message.groupAccessKey } : {}) });
539
+ }
540
+ }
432
541
  if (this.stopped || lane.generation !== generation)
433
542
  return;
434
543
  const controller = new AbortController();
@@ -447,6 +556,8 @@ export class BotBridge {
447
556
  this.audit({ stage: 'live-card-unconfirmed', messageId: message.messageId });
448
557
  };
449
558
  try {
559
+ const input = await prepareMedia({ ...message, text }, this.transport, controller.signal, { images: this.config.imageInput !== false, voice: this.config.voiceInput === true, maxText: this.config.maxInputChars });
560
+ text = input.text;
450
561
  await this.ensureSession(message, lane);
451
562
  if (controller.signal.aborted) {
452
563
  if (timedOut)
@@ -469,7 +580,7 @@ export class BotBridge {
469
580
  else
470
581
  this.liveUnavailableUntil = Date.now() + LIVE_RETRY_MS;
471
582
  }
472
- const turnContext = { memoryText, ...(artifactsText ? { artifactsText } : {}) };
583
+ const turnContext = { memoryText, ...(artifactsText ? { artifactsText } : {}), ...(input.images ? { images: input.images } : {}) };
473
584
  // Only the owner's harness-mode chat can be asked anything; a restricted session gets no hooks at all.
474
585
  const observer = live || harness ? {
475
586
  ...(live ? { stream: (event) => live?.push(event) } : {}),
@@ -509,7 +620,7 @@ export class BotBridge {
509
620
  clearTimeout(timer);
510
621
  if (error instanceof ArtifactDeliveryError)
511
622
  throw error;
512
- const known = error instanceof DshRuntimeError || error instanceof MemoryStoreError || error instanceof SessionStoreError || error instanceof ModelPreferencesError || error instanceof PresentationError;
623
+ const known = error instanceof DshRuntimeError || error instanceof MemoryStoreError || error instanceof SessionStoreError || error instanceof ModelPreferencesError || error instanceof PresentationError || error instanceof MediaInputError;
513
624
  if (live) {
514
625
  const stopped = timedOut || controller.signal.aborted || this.stopped;
515
626
  await conclude({ phase: stopped ? 'stopped' : 'failed', note: timedOut ? TIMEOUT_TEXT : this.stopped ? STOPPING_TEXT
@@ -678,7 +789,7 @@ export class BotBridge {
678
789
  async deliverFiles(message, lane, files, signal) {
679
790
  if (!files.length || signal.aborted || this.stopped)
680
791
  return;
681
- const workspace = await realpath(lane.user.cwd).catch(() => undefined);
792
+ const workspace = await realpath(lane.saved?.cwd ?? lane.user.cwd).catch(() => undefined);
682
793
  const notes = [];
683
794
  for (const [index, file] of files.entries()) {
684
795
  if (signal.aborted || this.stopped)
@@ -800,11 +911,95 @@ export class BotBridge {
800
911
  this.audit({ stage: 'feedback-updated', messageId });
801
912
  });
802
913
  }
914
+ topicsEnabled() { return Boolean(this.state.topics && this.transport.openTopic); }
915
+ topicScope(message) {
916
+ return { ownerKey: ownerKey(message, this.config), chatKey: createHash('sha256').update(JSON.stringify([
917
+ this.config.profile, this.config.botOpenId, message.chatType, message.chatId,
918
+ ])).digest('hex') };
919
+ }
920
+ newTopicKey(message) {
921
+ return createHash('sha256').update(JSON.stringify([sessionKey({ ...message, threadId: '' }, this.config),
922
+ message.requestId ?? message.messageId, 'lark-topic-v1'])).digest('hex');
923
+ }
924
+ entryKey(message) {
925
+ return createHash('sha256').update(JSON.stringify([sessionKey({ ...message, threadId: '' }, this.config), 'lark-entry-v1'])).digest('hex');
926
+ }
927
+ reserveTopic(message) {
928
+ const key = this.topicScope(message).chatKey;
929
+ const pending = this.topicCreations.get(key) ?? new Set();
930
+ let done;
931
+ const promise = new Promise(resolve => { done = resolve; });
932
+ pending.add(promise);
933
+ this.topicCreations.set(key, pending);
934
+ return () => { done(); pending.delete(promise); if (!pending.size && this.topicCreations.get(key) === pending)
935
+ this.topicCreations.delete(key); };
936
+ }
937
+ async createTopic(message, title, conversationKey, sessionId) {
938
+ const idempotencyKey = createHash('sha256').update(JSON.stringify([this.config.botOpenId,
939
+ message.requestId ?? message.messageId, 'topic', sessionId ?? 'new'])).digest('hex').slice(0, 40);
940
+ const target = { chatId: message.chatId,
941
+ ...(!message.threadId && !message.menu && !message.requestId?.startsWith('card:') && !sessionId && !message.text.startsWith('/')
942
+ ? { messageId: message.messageId } : {}) };
943
+ let receipt;
944
+ const release = this.reserveTopic(message);
945
+ try {
946
+ if (message.chatType === 'p2p') {
947
+ const context = this.permissionContext(message);
948
+ for (const lane of this.lanes.values()) {
949
+ if (lane.permissionContext?.ownerKey === context.ownerKey && lane.permissionContext.conversationKey === context.conversationKey) {
950
+ await this.adoptPermission(lane);
951
+ }
952
+ }
953
+ }
954
+ if (!sessionId && this.state.modelPreferences) {
955
+ const user = this.config.users.find(item => item.openId === message.senderId);
956
+ if (!this.sharedModel(message, { user })) {
957
+ const targetContext = { ownerKey: ownerKey(message, this.config), conversationKey };
958
+ // Capture the current topic's choice, or the main chat's selection,
959
+ // once. Later main-chat changes must not retarget an existing topic.
960
+ const source = await this.state.modelPreferences.get(this.context(message))
961
+ ?? await this.state.modelPreferences.get({ ...targetContext, conversationKey: this.entryKey(message) })
962
+ ?? await this.state.modelPreferences.get({ ...targetContext, conversationKey: sessionKey({ ...message, threadId: '' }, this.config) });
963
+ if (source && !await this.state.modelPreferences.get(targetContext)) {
964
+ await this.state.modelPreferences.set(targetContext, source.selection, { expectedRevision: null });
965
+ }
966
+ }
967
+ }
968
+ const send = async () => {
969
+ if (!this.stopped)
970
+ receipt = await this.transport.openTopic(target, title, { idempotencyKey, chatType: message.chatType, textOnly: this.config.responseMode === 'text' });
971
+ };
972
+ // Do not read or mutate MemoryStore inside its authorization/send critical section.
973
+ if (message.chatType === 'group')
974
+ await this.state.memories.withGroupAccess(this.context(message), message.groupAccessKey, send);
975
+ else
976
+ await send();
977
+ if (!receipt)
978
+ throw new Error();
979
+ return await this.state.topics.record({ ...this.topicScope(message), threadId: receipt.threadId, messageId: receipt.messageId,
980
+ ...(receipt.link ? { link: receipt.link } : {}),
981
+ laneKey: sessionId ? createHash('sha256').update(JSON.stringify([conversationKey, sessionId, 'topic-lane-v1'])).digest('hex') : conversationKey,
982
+ conversationKey, ...(sessionId ? { sessionId } : {}), ...(message.groupAccessKey ? { groupAccessKey: message.groupAccessKey } : {}) });
983
+ }
984
+ catch {
985
+ this.audit({ stage: 'topic-creation-unconfirmed', messageId: message.messageId });
986
+ throw new TopicDeliveryError('话题创建结果未能确认,已停止处理;请检查聊天记录。');
987
+ }
988
+ finally {
989
+ release();
990
+ }
991
+ }
803
992
  context(message) {
804
- return { ownerKey: ownerKey(message, this.config), conversationKey: sessionKey(message, this.config), privateChat: message.chatType === 'p2p' };
993
+ const routed = message;
994
+ return { ownerKey: ownerKey(message, this.config), conversationKey: routed.topic?.conversationKey ?? routed.conversationKey
995
+ ?? (this.topicsEnabled() && !message.threadId ? this.entryKey(message) : sessionKey(message, this.config)), privateChat: message.chatType === 'p2p' };
805
996
  }
806
997
  sessionContext(message) {
807
- const context = this.context(message);
998
+ return { ...this.catalogContext(message, this.context(message).conversationKey),
999
+ ...(message.topic?.sessionId ? { sessionId: message.topic.sessionId } : {}) };
1000
+ }
1001
+ catalogContext(message, conversationKey) {
1002
+ const context = { ownerKey: ownerKey(message, this.config), conversationKey, privateChat: message.chatType === 'p2p' };
808
1003
  if (message.chatType === 'p2p')
809
1004
  return context;
810
1005
  if (!message.groupAccessKey)
@@ -815,11 +1010,57 @@ export class BotBridge {
815
1010
  context.conversationKey, 'group-memory-v1', message.groupAccessKey,
816
1011
  ])).digest('hex') };
817
1012
  }
818
- policy(message, lane, selection) {
1013
+ /** Only this user's chat and current disclosure epoch participate in the picker. */
1014
+ async topicSessions(message) {
1015
+ const topics = (await this.state.topics.list(this.topicScope(message)))
1016
+ .filter(item => item.groupAccessKey === message.groupAccessKey);
1017
+ const contexts = new Set([sessionKey({ ...message, threadId: '' }, this.config), this.context(message).conversationKey,
1018
+ ...topics.map(item => item.conversationKey)]);
1019
+ const sessions = await Promise.all([...contexts].map(async (conversationKey) => (await this.state.sessions.list(this.catalogContext(message, conversationKey)))
1020
+ .map(session => ({ ...session, conversationKey, topic: topics.find(item => item.sessionId === session.id) }))));
1021
+ return [...new Map(sessions.flat().map(session => [session.id, session])).values()]
1022
+ .sort((a, b) => b.updatedAt - a.updatedAt || a.id.localeCompare(b.id));
1023
+ }
1024
+ async continueTopic(message, session) {
1025
+ let binding = session.topic;
1026
+ if (!binding) {
1027
+ const key = JSON.stringify([this.topicScope(message), session.id]);
1028
+ let pending = this.topicResumes.get(key);
1029
+ if (!pending) {
1030
+ pending = (async () => {
1031
+ // Another lane may have finished since this picker took its snapshot.
1032
+ const existing = (await this.state.topics.list(this.topicScope(message)))
1033
+ .find(item => item.sessionId === session.id && item.groupAccessKey === message.groupAccessKey);
1034
+ return existing ?? this.createTopic(message, session.title, session.conversationKey, session.id);
1035
+ })();
1036
+ this.topicResumes.set(key, pending);
1037
+ }
1038
+ try {
1039
+ binding = await pending;
1040
+ }
1041
+ finally {
1042
+ if (this.topicResumes.get(key) === pending)
1043
+ this.topicResumes.delete(key);
1044
+ }
1045
+ }
1046
+ if (!binding.link && this.transport.topicLink) {
1047
+ const link = await this.transport.topicLink(binding.messageId).catch(() => undefined);
1048
+ if (link)
1049
+ binding = await this.state.topics.record({ ...binding, link });
1050
+ }
1051
+ return binding;
1052
+ }
1053
+ topicNavigation(binding, title) {
1054
+ return binding.link ? `已准备好「${title}」。[打开话题继续](${binding.link})。`
1055
+ : `已准备好「${title}」。请在聊天记录中打开对应话题继续。`;
1056
+ }
1057
+ policy(message, lane, selection, cwd) {
819
1058
  const context = this.context(message);
1059
+ const harness = message.chatType === 'p2p' && lane.user.directMode === 'harness';
820
1060
  return {
821
- cwd: lane.user.cwd, allowedTools: lane.user.allowedTools,
822
- mode: message.chatType === 'p2p' && lane.user.directMode === 'harness' ? 'harness' : 'restricted',
1061
+ cwd: harness && cwd ? cwd : lane.user.cwd, allowedTools: lane.user.allowedTools,
1062
+ mode: harness ? 'harness' : 'restricted',
1063
+ ...(harness && lane.user.agentPreset ? { agentPreset: lane.user.agentPreset } : {}),
823
1064
  provider: this.config.provider, model: this.config.model,
824
1065
  presentation: Boolean(this.state.artifacts),
825
1066
  ...selection,
@@ -855,6 +1096,8 @@ export class BotBridge {
855
1096
  }
856
1097
  if (lane.session) {
857
1098
  await this.adoptPermission(lane);
1099
+ if (this.topicsEnabled())
1100
+ await this.applyPermission(message, lane, lane.session, false);
858
1101
  return;
859
1102
  }
860
1103
  }
@@ -863,7 +1106,7 @@ export class BotBridge {
863
1106
  // Like the App: a shared session starts on the host default and resumes on its own record.
864
1107
  const selection = this.sharedModel(message, lane) ? undefined : modelPreference?.selection;
865
1108
  const handle = saved
866
- ? await this.runtime.resume(this.policy(message, lane, selection), saved.nativeId)
1109
+ ? await this.runtime.resume(this.policy(message, lane, selection, saved.cwd), saved.nativeId)
867
1110
  : await this.runtime.create(this.policy(message, lane, selection));
868
1111
  try {
869
1112
  await handle.checkpoint();
@@ -872,7 +1115,10 @@ export class BotBridge {
872
1115
  // Publish a route only after even the empty native log is durable, and
873
1116
  // before submitting the first prompt. An abandoned creation has no work.
874
1117
  lane.saved = saved ? await this.bindRestoredSession(context, saved, handle)
875
- : await this.state.sessions.add(context, handle.id, preview(message.text, 40) || '新会话');
1118
+ : await this.state.sessions.add(context, handle.id, preview(message.text, 40) || '新会话', lane.user.cwd);
1119
+ if (message.topic && !message.topic.sessionId) {
1120
+ message.topic = await this.state.topics.record({ ...message.topic, sessionId: lane.saved.id });
1121
+ }
876
1122
  lane.restored = Boolean(saved);
877
1123
  lane.session = handle;
878
1124
  }
@@ -939,6 +1185,11 @@ export class BotBridge {
939
1185
  if (command === 'new' || command === 'reset' || command === 'clear') {
940
1186
  if (args)
941
1187
  return '用法:/new;创建新会话后可用 /rename <标题> 命名。';
1188
+ if (this.topicsEnabled()) {
1189
+ const binding = await this.createTopic(message, '新话题', this.newTopicKey(message));
1190
+ message.topicResult = { binding, title: '新话题', newEntry: true };
1191
+ return this.topicNavigation(binding, '新话题');
1192
+ }
942
1193
  await this.unload(lane);
943
1194
  await this.state.sessions.archive(conversation);
944
1195
  lane.saved = undefined;
@@ -946,14 +1197,17 @@ export class BotBridge {
946
1197
  return '好,我们换个话题。之前的对话还在,需要时可以用 /sessions 找回来。';
947
1198
  }
948
1199
  if (command === 'sessions') {
949
- const sessions = await this.state.sessions.list(conversation);
1200
+ const sessions = this.topicsEnabled() ? await this.topicSessions(message) : await this.state.sessions.list(conversation);
950
1201
  const current = await this.state.sessions.current(conversation);
1202
+ const view = message.sessionView ?? sessionViewCommandParse(message.text)?.view ?? { page: 0, query: '' };
1203
+ const page = sessionPage(sessions, view);
951
1204
  if (args !== 'detail')
952
- return sessions.length ? '你在这里聊过的话题:\n' + sessions.slice(0, 20)
953
- .map(s => `${s.id === current?.id ? '正在聊 · ' : ''}${s.title}`).join('\n')
954
- + '\n可以点卡片里的话题继续;没有按钮时,发送 /sessions detail 查看可用操作。'
1205
+ return sessions.length ? `当前聊天的话题(${page.total} 个${view.query ? '匹配' : ''},第 ${page.view.page + 1}/${page.pages} 页):\n` + page.items
1206
+ .map(s => `${s.id === current?.id ? '正在聊 · ' : ''}${s.title}\n/resume ${s.id}`).join('\n')
1207
+ + '\n可在卡片中搜索或选择话题。文字操作:/sessions page <页码>。'
955
1208
  : '这里还没有以前的对话,直接开始聊就好。';
956
- return sessions.length ? `当前聊天/话题的会话(${sessions.length} 个):\n` + sessions.slice(0, 20).map(s => `${s.id}${s.id === current?.id ? ' [当前]' : ''} · ${s.title}`).join('\n') + '\n用 /resume <ID> 切换;只显示你在当前聊天/话题的历史。' : '当前聊天/话题还没有保存的会话。';
1209
+ return sessions.length ? `当前聊天的会话(${sessions.length} 个):\n` + sessions.slice(0, 20).map(s => `${s.id}${s.id === current?.id ? ' [当前]' : ''} · ${s.title}`).join('\n')
1210
+ + (this.topicsEnabled() ? '\n用 /resume <ID> 打开对应话题;只显示你在当前聊天有权访问的历史。' : '\n用 /resume <ID> 切换;只显示你在当前聊天/话题的历史。') : '当前聊天/话题还没有保存的会话。';
957
1211
  }
958
1212
  if (command === 'rename') {
959
1213
  if (!args)
@@ -965,15 +1219,31 @@ export class BotBridge {
965
1219
  if (command === 'resume') {
966
1220
  if (!/^[a-f0-9]{12}$/.test(args))
967
1221
  return '用法:/resume <ID>;请从当前聊天的 /sessions 列表选择。';
1222
+ if (this.topicsEnabled()) {
1223
+ const sessions = await this.topicSessions(message);
1224
+ const target = (message.sessionView ? sessionPage(sessions, message.sessionView).items : sessions).find(session => session.id === args);
1225
+ if (!target) {
1226
+ if (message.sessionView)
1227
+ throw new SessionStoreError('NOT_FOUND');
1228
+ return '在你当前的聊天中找不到这个会话。';
1229
+ }
1230
+ const binding = await this.continueTopic(message, target);
1231
+ message.topicResult = { binding, title: target.title, newEntry: !target.topic };
1232
+ return this.topicNavigation(binding, target.title);
1233
+ }
968
1234
  // Resolve authorization before touching the running handle or native log.
969
- const target = (await this.state.sessions.list(conversation)).find(s => s.id === args);
970
- if (!target)
1235
+ const sessions = await this.state.sessions.list(conversation);
1236
+ const target = (message.sessionView ? sessionPage(sessions, message.sessionView).items : sessions).find(s => s.id === args);
1237
+ if (!target) {
1238
+ if (message.sessionView)
1239
+ throw new SessionStoreError('NOT_FOUND');
971
1240
  return '在你当前的聊天/话题中找不到这个会话。';
1241
+ }
972
1242
  if (!lane.unavailable && lane.session && lane.saved?.id === target.id)
973
1243
  return `已经在会话「${target.title}」中。`;
974
1244
  await this.unload(lane);
975
1245
  const preference = await this.state.modelPreferences?.get(context);
976
- const handle = await this.runtime.resume(this.policy(message, lane, this.sharedModel(message, lane) ? undefined : preference?.selection), target.nativeId);
1246
+ const handle = await this.runtime.resume(this.policy(message, lane, this.sharedModel(message, lane) ? undefined : preference?.selection, target.cwd), target.nativeId);
977
1247
  try {
978
1248
  await handle.checkpoint();
979
1249
  await this.applyPermission(message, lane, handle, false);
@@ -1082,7 +1352,7 @@ export class BotBridge {
1082
1352
  return;
1083
1353
  if (view.current)
1084
1354
  return view;
1085
- const saved = await this.state.permissionPreferences?.get(this.context(message));
1355
+ const saved = await this.state.permissionPreferences?.get(this.permissionContext(message));
1086
1356
  return saved && view.options.some(option => option.id === saved) ? { ...view, current: saved } : view;
1087
1357
  }
1088
1358
  /** Owner-only: switch this private chat's Harness permission preset, like the App's selector. */
@@ -1099,7 +1369,7 @@ export class BotBridge {
1099
1369
  if (!option)
1100
1370
  return '请通过权限模式卡片选择,或发送 /permission 查看可用选项。';
1101
1371
  // Save first: a later session in this chat starts in the chosen mode even if the live switch fails.
1102
- await this.state.permissionPreferences?.set(this.context(message), option.id);
1372
+ await this.state.permissionPreferences?.set(this.permissionContext(message), option.id);
1103
1373
  if (lane.session?.setPermission) {
1104
1374
  lane.session.setPermission(option.id);
1105
1375
  lane.permissionSeen = option.id;
@@ -1111,7 +1381,7 @@ export class BotBridge {
1111
1381
  async applyPermission(message, lane, handle, fresh) {
1112
1382
  if (!this.harness(message, lane) || !handle.setPermission)
1113
1383
  return;
1114
- const { ownerKey, conversationKey } = this.context(message);
1384
+ const { ownerKey, conversationKey } = this.permissionContext(message);
1115
1385
  lane.permissionContext = { ownerKey, conversationKey };
1116
1386
  const saved = await this.state.permissionPreferences?.get(lane.permissionContext);
1117
1387
  const state = handle.permissionState?.();
@@ -1121,6 +1391,10 @@ export class BotBridge {
1121
1391
  handle.setPermission(target);
1122
1392
  lane.permissionSeen = handle.permissionState?.()?.current;
1123
1393
  }
1394
+ permissionContext(message) {
1395
+ return this.topicsEnabled() ? { ownerKey: ownerKey(message, this.config),
1396
+ conversationKey: sessionKey({ ...message, threadId: '' }, this.config) } : this.context(message);
1397
+ }
1124
1398
  /** A preset changed in the App on this chat's live session becomes the chat's choice, so new sessions keep it. */
1125
1399
  async adoptPermission(lane) {
1126
1400
  const live = lane.permissionContext ? lane.session?.permissionState?.() : undefined;
@@ -1247,6 +1521,12 @@ export class BotBridge {
1247
1521
  async commandPanel(message, lane, card) {
1248
1522
  const match = /^\/(\S+)(?:\s+([\s\S]*))?$/.exec(message.text);
1249
1523
  const command = match?.[1]?.toLowerCase(), args = (match?.[2] ?? '').trim();
1524
+ if (message.topicResult && command !== 'new' && !message.sessionView && !card?.pending && !card?.error && !card?.notice) {
1525
+ const { binding, title } = message.topicResult;
1526
+ const text = this.topicNavigation(binding, title);
1527
+ return { title: '话题已就绪', tone: 'success', summary: `打开「${preview(title, 40)}」继续`,
1528
+ sections: [{ title: preview(title, 40), text }], note: message.chatType === 'group' ? '在话题里继续 @机器人。' : '在对应话题中发消息,就能接着之前的上下文。' };
1529
+ }
1250
1530
  const choice = /^use (\S+)$/.exec(args)?.[1];
1251
1531
  const pending = card?.pending ? choice : undefined;
1252
1532
  const interactive = this.cardActionsReady && this.config.cardInteractions && Boolean(this.state.actions);
@@ -1283,44 +1563,31 @@ export class BotBridge {
1283
1563
  }
1284
1564
  // Ordinary conversation and diagnostics stay out of management cards.
1285
1565
  // A card is useful here because the user explicitly wants to pick a topic.
1286
- const topic = card && (command === 'resume' || command === 'new');
1566
+ const topic = (card || message.sessionView || message.topicResult) && (command === 'resume' || command === 'new');
1287
1567
  if ((command !== 'sessions' || args === 'detail') && !topic)
1288
1568
  return;
1289
1569
  const conversation = this.sessionContext(message);
1290
1570
  const current = await this.state.sessions.current(conversation);
1291
- const sessions = await this.state.sessions.list(conversation);
1292
- if (!sessions.length)
1293
- return;
1571
+ const sessions = this.topicsEnabled() ? await this.topicSessions(message) : await this.state.sessions.list(conversation);
1294
1572
  const busy = Boolean(card?.pending);
1295
1573
  const target = command === 'resume' ? sessions.find(s => s.id === args) : undefined;
1296
1574
  const status = card?.error ? { text: card.error, tone: 'error' }
1297
- : busy ? { text: target ? `正在切换到「${preview(target.title, 40)}」…` : '正在开始新话题…', tone: 'pending' }
1298
- : topic && command === 'resume' && target ? { text: `已切换到「${preview(target.title, 40)}」,可以接着聊了。`, tone: 'success' }
1299
- : topic ? { text: '好,换个话题。直接发消息就会开始新的对话,之前的还在这里。', tone: 'success' }
1300
- : card?.notice ? { text: card.notice } : undefined;
1301
- const actions = [];
1302
- const add = (label, command, options = {}) => {
1303
- if (busy) {
1304
- actions.push({ label, value: {}, disabled: true });
1305
- return;
1306
- }
1575
+ : busy ? { text: target ? `正在打开「${preview(target.title, 40)}」…` : command === 'new' ? '正在开始新话题…' : '正在查找话题…', tone: 'pending' }
1576
+ : card?.notice ? { text: card.notice }
1577
+ : topic && command === 'resume' && target ? undefined
1578
+ : command === 'new' && message.topicResult ? { text: message.topicResult.binding.link
1579
+ ? `新话题已创建。[进入新话题](${message.topicResult.binding.link})` : '新话题已创建,请在新卡片下方回复话题。' }
1580
+ : topic ? { text: '好,换个话题。直接发消息就会开始新的对话,之前的还在这里。', tone: 'success' }
1581
+ : undefined;
1582
+ return sessionControlPanel(sessions.map(session => ({ ...session, ...('topic' in session && session.topic?.link
1583
+ ? { link: session.topic.link } : {}) })), current?.id, (label, command) => {
1307
1584
  if (!interactive)
1308
1585
  return;
1309
- const token = this.state.actions.issue({ ownerId: message.senderId, chatId: message.chatId,
1310
- chatType: message.chatType, threadId: message.threadId, sourceMessageId: message.messageId,
1311
- command, ...(options.expectedSessionId === undefined ? {} : { expectedSessionId: options.expectedSessionId }) });
1312
- actions.push({ label, value: { token }, confirm: options.confirm });
1313
- };
1314
- for (const session of sessions.filter(s => s.id !== current?.id).slice(0, 5))
1315
- add(`继续 ${preview(session.title, 14)}`, `/resume ${session.id}`, { expectedSessionId: current?.id ?? null, confirm: { title: '接着这个话题聊?', text: `继续「${preview(session.title, 40)}」,现在的对话会保留。` } });
1316
- if (current)
1317
- add('换个话题', '/new', { expectedSessionId: current.id,
1318
- confirm: { title: '开始新话题?', text: '之前的对话会保留,需要时可以再找回来。' } });
1319
- return { title: '以前的对话', subtitle: '选一个话题,接着聊', tone: status?.tone ?? 'normal', ...(status ? { status } : {}),
1320
- summary: status?.text ?? '选择以前的话题继续,或开始一段新对话', actions,
1321
- sections: sessions.slice(0, 5).map(s => ({ title: preview(s.title, 40), text: s.id === current?.id ? '正在聊这个话题' : '可以接着聊' })),
1322
- ...(!interactive || sessions.length > 5 ? { note: '完整列表和更多操作:/sessions detail' } : {}),
1323
- };
1586
+ if (busy)
1587
+ return { label, value: {}, disabled: true };
1588
+ return { label, value: { token: issue(command, /^(?:\/new|\/sessions pick )/.test(command) ? { expectedSessionId: current?.id ?? null } : {}) } };
1589
+ }, { view: message.sessionView ?? sessionViewCommandParse(message.text)?.view,
1590
+ selectedId: !card?.error && !card?.notice && !busy ? target?.id : undefined, busy, status });
1324
1591
  }
1325
1592
  async reply(message, text, signal) {
1326
1593
  return this.sendPayloads(message, renderAnswer(text, this.config.chunkBytes), signal);