@rynx-ai/plugin-channel-lark 0.1.9

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/dist/cli-mirror/mirror-index.d.ts +46 -0
  2. package/dist/cli-mirror/mirror-index.js +48 -0
  3. package/dist/cli-mirror/rollout-mirror.d.ts +85 -0
  4. package/dist/cli-mirror/rollout-mirror.js +333 -0
  5. package/dist/cli-mirror/rollout-watcher.d.ts +109 -0
  6. package/dist/cli-mirror/rollout-watcher.js +347 -0
  7. package/dist/host-adapters.d.ts +82 -0
  8. package/dist/host-adapters.js +404 -0
  9. package/dist/index.d.ts +7 -0
  10. package/dist/index.js +7 -0
  11. package/dist/lark-card-stream.d.ts +363 -0
  12. package/dist/lark-card-stream.js +1335 -0
  13. package/dist/lark-content.d.ts +30 -0
  14. package/dist/lark-content.js +138 -0
  15. package/dist/lark-conversation-directory.d.ts +65 -0
  16. package/dist/lark-conversation-directory.js +106 -0
  17. package/dist/lark-diff-card.d.ts +25 -0
  18. package/dist/lark-diff-card.js +58 -0
  19. package/dist/lark-fork-transcript.d.ts +16 -0
  20. package/dist/lark-fork-transcript.js +141 -0
  21. package/dist/lark-resume-card.d.ts +31 -0
  22. package/dist/lark-resume-card.js +105 -0
  23. package/dist/lark-sdk/client.d.ts +8 -0
  24. package/dist/lark-sdk/client.js +32 -0
  25. package/dist/lark-sdk/index.d.ts +1 -0
  26. package/dist/lark-sdk/index.js +1 -0
  27. package/dist/lark-sender.d.ts +114 -0
  28. package/dist/lark-sender.js +323 -0
  29. package/dist/lark-session-store.d.ts +13 -0
  30. package/dist/lark-session-store.js +14 -0
  31. package/dist/lark-settings-card.d.ts +55 -0
  32. package/dist/lark-settings-card.js +158 -0
  33. package/dist/lark-setup.d.ts +17 -0
  34. package/dist/lark-setup.js +86 -0
  35. package/dist/lark.d.ts +500 -0
  36. package/dist/lark.js +2010 -0
  37. package/dist/runtime.d.ts +16 -0
  38. package/dist/runtime.js +157 -0
  39. package/dist/runtime.mjs +130863 -0
  40. package/package.json +35 -0
  41. package/rynx-plugin.json +42 -0
package/dist/lark.js ADDED
@@ -0,0 +1,2010 @@
1
+ import { execFile } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
3
+ import { mkdir } from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { promisify } from "node:util";
6
+ import { CodexRuntimeError, collectLiveRun, } from "@rynx-ai/core";
7
+ const execFileAsync = promisify(execFile);
8
+ import { LarkSessionCardController, } from "./lark-card-stream.js";
9
+ import { buildLarkCardReplyPrompt, chunkLarkReplyText, extractLarkTextPrompt, isLarkNewSessionCommand, isLarkStopCommand, parseLarkCommand, } from "./lark-content.js";
10
+ import { deriveConversationShortId, MemoryLarkConversationDirectory, } from "./lark-conversation-directory.js";
11
+ import { buildDiffCardJson } from "./lark-diff-card.js";
12
+ import { buildForkTranscript } from "./lark-fork-transcript.js";
13
+ import { buildChatApplink, buildOpenChatCardJson, buildResumeCardJson, } from "./lark-resume-card.js";
14
+ import { LarkClientProvider, LarkSdkMessageSender } from "./lark-sender.js";
15
+ import { buildSettingsCard, buildSettingsCardJson, buildSettingsResultCard, SETTINGS_DEFAULT_VALUE, SETTINGS_EFFORT_VALUES, } from "./lark-settings-card.js";
16
+ import { getRuntimeProfile, isAgentRuntimeId, RUNTIME_PROFILES, } from "@rynx-ai/core";
17
+ import { SteerController } from "@rynx-ai/core";
18
+ import { toErrorMessage } from "@rynx-ai/core";
19
+ import { findInnermostAllowedRoot } from "@rynx-ai/core";
20
+ export { chunkLarkReplyText, extractLarkTextPrompt, isLarkNewSessionCommand, isLarkStopCommand, };
21
+ export { LarkClientProvider, LarkSdkMessageSender };
22
+ const DEFAULT_UNSUPPORTED_MESSAGE = "暂时只支持文本消息,请发送文本内容。";
23
+ const DEFAULT_EMPTY_MESSAGE = "请发送文本内容。";
24
+ const DEFAULT_EMPTY_RESPONSE = "抱歉,我暂时没有生成回复。";
25
+ const DEFAULT_NEW_SESSION_RESPONSE = "已开启新会话。";
26
+ const DEFAULT_STOP_NO_INFLIGHT_RESPONSE = "当前没有正在进行的任务。";
27
+ const DEFAULT_STOP_ACK_RESPONSE = "已停止当前任务。";
28
+ const LARK_WORK_STARTED_REACTION = "Get";
29
+ const LARK_WORK_COMPLETED_REACTION = "DONE";
30
+ /** Reaction added to a message whose text was steered into the running turn. */
31
+ const LARK_STEER_ACK_REACTION = "Get";
32
+ const LARK_COMMANDS = [
33
+ { name: "new", help: "/new — 开启新会话", category: "takeover" },
34
+ { name: "stop", help: "/stop 或 /cancel — 停止当前正在进行的任务", category: "other" },
35
+ { name: "runtime", help: "/runtime — 卡片切换运行时(codex/traex/claude)/ 模型 / 思考强度", category: "other" },
36
+ { name: "model", help: "/model — 卡片切换当前运行时的模型 / 思考强度", category: "other" },
37
+ { name: "agent", help: "/agent [名称] — 列出/切换声明式 agent(运行时+模型+提示+权限的预设)", category: "other" },
38
+ { name: "models", help: "/models — 列出当前运行时可用模型", category: "info" },
39
+ { name: "resume", help: "/resume [短id] — 列出最近会话/跳转或转发到指定会话", category: "takeover" },
40
+ { name: "fork", help: "/fork — 把当前会话 fork 到新群/新话题(带上下文+历史记录)", category: "takeover" },
41
+ { name: "cwd", help: "/cwd [目录] — 查看/绑定当前会话的工作目录(不存在则新建)", category: "takeover" },
42
+ { name: "goal", help: "/goal [目标|clear] — 查看/设置/清除当前会话目标", category: "action" },
43
+ { name: "diff", help: "/diff — 显示当前工作区 git 改动(含未跟踪文件)", category: "action" },
44
+ { name: "help", help: "/help — 显示本帮助", category: "info" },
45
+ ];
46
+ function larkCommandNames(category) {
47
+ return new Set(LARK_COMMANDS.filter((c) => c.category === category).map((c) => c.name));
48
+ }
49
+ const LARK_TAKEOVER_COMMANDS = larkCommandNames("takeover");
50
+ const LARK_INFO_COMMANDS = larkCommandNames("info");
51
+ const LARK_ACTION_COMMANDS = larkCommandNames("action");
52
+ const LARK_HELP_TEXT = ["可用命令:", ...LARK_COMMANDS.map((c) => c.help)].join("\n");
53
+ const LARK_BACKEND_UNSUPPORTED = "当前后端不支持该命令(仅 app_server 后端可用)。";
54
+ const LARK_NO_ACTIVE_SESSION = "请先发一条消息开始会话,再使用该命令。";
55
+ const DIFF_REPLY_MAX_BYTES = 60 * 1024;
56
+ /** Max diff body embedded in the `/diff` card (kept under Feishu's card size limit). */
57
+ const DIFF_CARD_MAX_BYTES = 16 * 1024;
58
+ const defaultLogger = {
59
+ log(entry) {
60
+ console.log(JSON.stringify({
61
+ level: "info",
62
+ type: "lark",
63
+ ...entry,
64
+ }));
65
+ },
66
+ };
67
+ /** Route SDK diagnostics through the runner logger instead of stdout, which is
68
+ * reserved for the NDJSON RPC transport in an out-of-process plugin. */
69
+ function larkSdkLogger(logger) {
70
+ const write = (level, messages) => {
71
+ const text = messages
72
+ .map((message) => (message instanceof Error ? message.message : String(message)))
73
+ .join(" ")
74
+ .replace(/[A-Za-z0-9_-]{30,}/g, "***");
75
+ logger.log({ event: `sdk.${level}`, error: text });
76
+ };
77
+ return {
78
+ error: (...messages) => write("error", messages),
79
+ warn: (...messages) => write("warn", messages),
80
+ info: () => undefined,
81
+ debug: () => undefined,
82
+ trace: () => undefined,
83
+ };
84
+ }
85
+ export class LarkConversationKeyResolver {
86
+ resolve(event) {
87
+ const tenantKey = event.tenant_key ?? event.sender.tenant_key ?? "unknown-tenant";
88
+ const threadId = event.message.thread_id?.trim();
89
+ if (threadId) {
90
+ return `lark:${tenantKey}:thread:${threadId}`;
91
+ }
92
+ return `lark:${tenantKey}:chat:${event.message.chat_id}`;
93
+ }
94
+ }
95
+ export class PerThreadQueue {
96
+ tails = new Map();
97
+ enqueue(key, task) {
98
+ const previous = this.tails.get(key) ?? Promise.resolve();
99
+ const next = previous
100
+ .catch(() => undefined)
101
+ .then(task);
102
+ const tracked = next.finally(() => {
103
+ if (this.tails.get(key) === tracked) {
104
+ this.tails.delete(key);
105
+ }
106
+ });
107
+ this.tails.set(key, tracked);
108
+ return tracked;
109
+ }
110
+ }
111
+ export class LarkMessageDeduper {
112
+ ttlMs;
113
+ entries = new Map();
114
+ constructor(ttlMs = 60 * 60 * 1000) {
115
+ this.ttlMs = ttlMs;
116
+ }
117
+ markIfNew(messageId, now = Date.now()) {
118
+ this.prune(now);
119
+ const expiresAt = this.entries.get(messageId);
120
+ if (expiresAt && expiresAt > now) {
121
+ return false;
122
+ }
123
+ this.entries.set(messageId, now + this.ttlMs);
124
+ return true;
125
+ }
126
+ prune(now) {
127
+ for (const [messageId, expiresAt] of this.entries) {
128
+ if (expiresAt <= now) {
129
+ this.entries.delete(messageId);
130
+ }
131
+ }
132
+ }
133
+ }
134
+ /** One-line title for a session's registry row: collapse whitespace, trim, cap
135
+ * at 60 chars + ellipsis. Empty prompt ⇒ undefined (no title). */
136
+ function larkSessionTitle(prompt) {
137
+ const text = prompt.replace(/\s+/g, " ").trim();
138
+ if (!text)
139
+ return undefined;
140
+ return text.length <= 60 ? text : `${text.slice(0, 59)}…`;
141
+ }
142
+ /**
143
+ * Pull a model's supported reasoning efforts from a `model/list` entry,
144
+ * tolerating both the App Server camelCase (`supportedReasoningEfforts`) and the
145
+ * cache/snake (`supported_reasoning_levels`) spellings. Empty when unknown.
146
+ */
147
+ function extractSupportedEfforts(model) {
148
+ const raw = model.supportedReasoningEfforts ??
149
+ model.supported_reasoning_levels ??
150
+ model.supportedReasoningLevels;
151
+ if (!Array.isArray(raw)) {
152
+ return [];
153
+ }
154
+ const efforts = [];
155
+ const seen = new Set();
156
+ for (const item of raw) {
157
+ const record = item && typeof item === "object" && !Array.isArray(item)
158
+ ? item
159
+ : undefined;
160
+ const effort = typeof item === "string"
161
+ ? item.trim()
162
+ : typeof record?.reasoningEffort === "string"
163
+ ? record.reasoningEffort.trim()
164
+ : typeof record?.effort === "string"
165
+ ? record.effort.trim()
166
+ : "";
167
+ if (!/^[a-zA-Z0-9_-]{1,64}$/.test(effort) || seen.has(effort))
168
+ continue;
169
+ seen.add(effort);
170
+ efforts.push(effort);
171
+ }
172
+ return efforts;
173
+ }
174
+ export class LarkMessageIngestor {
175
+ conversationRuntime;
176
+ messageSender;
177
+ /** Default runtime for this bot; per-conversation choices override it. */
178
+ provider;
179
+ /** Default declarative agent for this bot; per-conversation `/agent` overrides it. */
180
+ defaultAgent;
181
+ /** Host-backed agent catalog for `/agent` and execution freezing. */
182
+ agentCatalog;
183
+ /** Transient per-conversation settings-card selections, keyed by sessionKey. */
184
+ pendingSettings = new Map();
185
+ cwd;
186
+ activeSessionStore;
187
+ conversationKeyResolver;
188
+ deduper;
189
+ queue;
190
+ logger;
191
+ cardThrottleMs;
192
+ capabilities;
193
+ conversationDirectory;
194
+ allowedRoots;
195
+ botOpenId;
196
+ disableP2p;
197
+ groupAllowlistEnabled;
198
+ groupAllowlist;
199
+ mirror;
200
+ sessionTitleWriter;
201
+ sessionProvisioner;
202
+ inFlight = new Map();
203
+ constructor({ conversationRuntime, messageSender, cwd, defaultRuntime = "codex", defaultAgent, agentCatalog, activeSessionStore, conversationKeyResolver = new LarkConversationKeyResolver(), deduper = new LarkMessageDeduper(), queue = new PerThreadQueue(), logger = defaultLogger, cardThrottleMs = 250, capabilities, conversationDirectory = new MemoryLarkConversationDirectory(), allowedRoots = [], botOpenId = null, disableP2p = false, groupAllowlistEnabled = false, groupAllowlist = [], mirror, sessionTitleWriter, sessionProvisioner, }) {
204
+ this.conversationRuntime = conversationRuntime;
205
+ this.messageSender = messageSender;
206
+ this.provider = defaultRuntime;
207
+ this.defaultAgent = defaultAgent;
208
+ this.agentCatalog = agentCatalog;
209
+ this.cwd = cwd;
210
+ this.activeSessionStore = activeSessionStore;
211
+ this.conversationKeyResolver = conversationKeyResolver;
212
+ this.deduper = deduper;
213
+ this.queue = queue;
214
+ this.logger = logger;
215
+ this.cardThrottleMs = cardThrottleMs;
216
+ this.capabilities = capabilities;
217
+ this.conversationDirectory = conversationDirectory;
218
+ this.allowedRoots = allowedRoots;
219
+ this.botOpenId = botOpenId ?? null;
220
+ this.disableP2p = disableP2p;
221
+ this.groupAllowlistEnabled = groupAllowlistEnabled;
222
+ this.groupAllowlist = new Set(groupAllowlist);
223
+ this.mirror = mirror;
224
+ this.sessionTitleWriter = sessionTitleWriter;
225
+ this.sessionProvisioner = sessionProvisioner;
226
+ }
227
+ /** Best-effort title update for the daemon-owned machine-session record. */
228
+ registerSession(sessionId, prompt) {
229
+ const title = larkSessionTitle(prompt);
230
+ if (!this.sessionTitleWriter || !title)
231
+ return;
232
+ void this.sessionTitleWriter(sessionId, title).catch((error) => {
233
+ this.logger.log({
234
+ event: "session_meta.title_failed",
235
+ session_id: sessionId,
236
+ error: toErrorMessage(error),
237
+ });
238
+ });
239
+ }
240
+ /**
241
+ * Set this bot's own `open_id` once it has been resolved asynchronously
242
+ * (the runtime fetches it from the bot info API at startup).
243
+ */
244
+ setBotOpenId(openId) {
245
+ this.botOpenId = openId ?? null;
246
+ }
247
+ /** Whether a bridge-driven turn is currently running for `sessionKey`. The
248
+ * CLI mirror reads this to skip turns already shown live in Feishu. */
249
+ isInFlight(sessionKey) {
250
+ return this.inFlight.has(sessionKey);
251
+ }
252
+ /**
253
+ * Whether a group message is addressed to *this* bot. With a known
254
+ * `botOpenId`, that means one of the `@`-mentions carries our `open_id` —
255
+ * so in a group hosting several bots, only the one actually mentioned runs.
256
+ * When `botOpenId` is unknown, fall back to "any mention" (legacy single-bot
257
+ * behavior) rather than going silent.
258
+ */
259
+ isAddressedToBot(event) {
260
+ const mentions = event.message.mentions ?? [];
261
+ if (!this.botOpenId) {
262
+ return mentions.length > 0;
263
+ }
264
+ return mentions.some((mention) => mention.id?.open_id === this.botOpenId);
265
+ }
266
+ /**
267
+ * Handle a Feishu CardKit button click. Supported actions:
268
+ * - `{ kind: "stop_turn", sessionKey }` — the in-card "停止" button.
269
+ * - `{ kind: "resume_forward", threadId, chatId }` — forward a topic thread
270
+ * into `chatId` (from the `/resume` card).
271
+ * Anything else is logged and ignored.
272
+ */
273
+ async ingestCardAction(event) {
274
+ const value = event.value;
275
+ const kind = typeof value?.kind === "string" ? value.kind : null;
276
+ if (kind === "resume_forward") {
277
+ return this.handleResumeForwardAction(event);
278
+ }
279
+ if (kind === "settings_select") {
280
+ return this.handleSettingsSelect(event);
281
+ }
282
+ if (kind === "settings_apply") {
283
+ return this.handleSettingsApply(event);
284
+ }
285
+ if (kind === "settings_cancel") {
286
+ return this.handleSettingsCancel(event);
287
+ }
288
+ if (kind !== "stop_turn") {
289
+ this.logger.log({
290
+ event: "card.action.unknown",
291
+ kind,
292
+ message_id: event.messageId,
293
+ });
294
+ return {};
295
+ }
296
+ const sessionKey = typeof value?.sessionKey === "string" ? value.sessionKey : null;
297
+ if (!sessionKey) {
298
+ this.logger.log({
299
+ event: "card.action.missing_session_key",
300
+ message_id: event.messageId,
301
+ });
302
+ return {};
303
+ }
304
+ const aborted = this.interruptSession(sessionKey);
305
+ this.logger.log({
306
+ event: aborted ? "session.stopped" : "session.stop_noop",
307
+ message_id: event.messageId,
308
+ session_key: sessionKey,
309
+ provider: this.provider,
310
+ source: "card_button",
311
+ });
312
+ // No toast — the cancellation appears as a grey blockquote at the bottom
313
+ // of the card via the executor's CodexRuntimeError → finalize path.
314
+ return {};
315
+ }
316
+ async handleResumeForwardAction(event) {
317
+ const value = event.value;
318
+ const sourceThreadId = typeof value?.sourceThreadId === "string" ? value.sourceThreadId : null;
319
+ const targetChatId = typeof value?.targetChatId === "string" ? value.targetChatId : null;
320
+ const targetThreadId = typeof value?.targetThreadId === "string" ? value.targetThreadId : null;
321
+ if (!sourceThreadId || !this.messageSender.forwardThread) {
322
+ return { toast: { type: "error", content: "无法转发该话题。" } };
323
+ }
324
+ // Forward into the current thread when /resume was invoked inside one, so
325
+ // it lands in-place instead of spawning a new thread in the topic group.
326
+ const receiveIdType = targetThreadId ? "thread_id" : "chat_id";
327
+ const receiveId = targetThreadId ?? targetChatId;
328
+ if (!receiveId) {
329
+ return { toast: { type: "error", content: "无法转发该话题。" } };
330
+ }
331
+ try {
332
+ await this.messageSender.forwardThread({
333
+ threadId: sourceThreadId,
334
+ receiveId,
335
+ receiveIdType,
336
+ uuid: randomUUID(),
337
+ });
338
+ this.logger.log({
339
+ event: "resume.forwarded",
340
+ message_id: event.messageId,
341
+ thread_id: sourceThreadId,
342
+ chat_id: receiveId,
343
+ });
344
+ return { toast: { type: "success", content: "已转发到当前会话" } };
345
+ }
346
+ catch (error) {
347
+ return { toast: { type: "error", content: `转发失败:${toErrorMessage(error)}` } };
348
+ }
349
+ }
350
+ /** Where a forward should land for the conversation `event` arrived in:
351
+ * inside the current topic thread when present, else the chat. */
352
+ forwardTargetOf(event) {
353
+ if (event.message.thread_id) {
354
+ return { receiveId: event.message.thread_id, receiveIdType: "thread_id" };
355
+ }
356
+ return { receiveId: event.message.chat_id, receiveIdType: "chat_id" };
357
+ }
358
+ /**
359
+ * Cancel the in-flight turn for `sessionKey`, if any. Returns true if a
360
+ * turn was actually aborted. Safe to call from outside the per-session
361
+ * queue (that's the whole point — the queue is serialized but interrupt
362
+ * must bypass it).
363
+ */
364
+ interruptSession(sessionKey) {
365
+ const turn = this.inFlight.get(sessionKey);
366
+ if (!turn) {
367
+ return false;
368
+ }
369
+ turn.abort.abort();
370
+ return true;
371
+ }
372
+ registerInFlight(sessionKey) {
373
+ const existing = this.inFlight.get(sessionKey);
374
+ if (existing) {
375
+ // Per-session queue should prevent overlap, but be defensive: abort
376
+ // any prior controller so it doesn't leak.
377
+ existing.abort.abort();
378
+ }
379
+ const turn = {
380
+ abort: new AbortController(),
381
+ steer: new SteerController(),
382
+ };
383
+ this.inFlight.set(sessionKey, turn);
384
+ return turn;
385
+ }
386
+ clearInFlight(sessionKey, turn) {
387
+ if (this.inFlight.get(sessionKey) === turn) {
388
+ this.inFlight.delete(sessionKey);
389
+ }
390
+ }
391
+ async ingest(event) {
392
+ const sessionKey = this.conversationKeyResolver.resolve(event);
393
+ const prompt = ["text", "post"].includes(event.message.message_type)
394
+ ? extractLarkTextPrompt(event)
395
+ : "";
396
+ if (!this.deduper.markIfNew(event.message.message_id)) {
397
+ this.logger.log({
398
+ event: "message.duplicate",
399
+ tenant_key: event.tenant_key ?? event.sender.tenant_key,
400
+ chat_id: event.message.chat_id,
401
+ thread_id: event.message.thread_id,
402
+ message_id: event.message.message_id,
403
+ session_key: sessionKey,
404
+ });
405
+ return;
406
+ }
407
+ // Access control: optionally forbid private (p2p) chats so the bot is
408
+ // group-only. p2p chats are always "addressed" to the bot, so a notice is
409
+ // sent back rather than going silently dark.
410
+ if (event.message.chat_type !== "group" && this.disableP2p) {
411
+ this.logger.log({
412
+ event: "message.access_denied",
413
+ tenant_key: event.tenant_key ?? event.sender.tenant_key,
414
+ chat_id: event.message.chat_id,
415
+ thread_id: event.message.thread_id,
416
+ message_id: event.message.message_id,
417
+ session_key: sessionKey,
418
+ reason: "p2p_disabled",
419
+ });
420
+ await this.sendReplyText(event, "私聊已被管理员禁用,请在授权的群聊中使用本机器人。").catch(() => { });
421
+ return;
422
+ }
423
+ // In a group, only run when *this* bot is the one `@`-mentioned. A group can
424
+ // host several bots, and every bot receives the message event — without this
425
+ // check, an `@` aimed at one bot would make all of them reply.
426
+ if (event.message.chat_type === "group" && !this.isAddressedToBot(event)) {
427
+ this.logger.log({
428
+ event: "message.ignored",
429
+ tenant_key: event.tenant_key ?? event.sender.tenant_key,
430
+ chat_id: event.message.chat_id,
431
+ thread_id: event.message.thread_id,
432
+ message_id: event.message.message_id,
433
+ session_key: sessionKey,
434
+ reason: (event.message.mentions?.length ?? 0) === 0
435
+ ? "group_message_without_mention"
436
+ : "group_message_not_addressed_to_bot",
437
+ });
438
+ return;
439
+ }
440
+ // Access control: when the group allowlist is enabled, only run in groups
441
+ // whose chat_id is listed. Checked after the mention gate above, so we only
442
+ // reply to messages actually addressed to this bot (not every group chatter).
443
+ if (event.message.chat_type === "group" &&
444
+ this.groupAllowlistEnabled &&
445
+ !this.groupAllowlist.has(event.message.chat_id)) {
446
+ this.logger.log({
447
+ event: "message.access_denied",
448
+ tenant_key: event.tenant_key ?? event.sender.tenant_key,
449
+ chat_id: event.message.chat_id,
450
+ thread_id: event.message.thread_id,
451
+ message_id: event.message.message_id,
452
+ session_key: sessionKey,
453
+ reason: "group_not_allowlisted",
454
+ });
455
+ await this.sendReplyText(event, "本群未在机器人白名单内,暂不可使用。").catch(() => { });
456
+ return;
457
+ }
458
+ // /stop bypasses the per-session queue on purpose — the in-flight turn
459
+ // is what we want to abort, and that turn is currently holding the queue.
460
+ if (isLarkStopCommand(prompt)) {
461
+ await this.handleStopCommand(event, sessionKey);
462
+ return;
463
+ }
464
+ const command = parseLarkCommand(prompt);
465
+ // While a turn is already running for this session, a new message acts on
466
+ // that turn instead of queueing behind it:
467
+ // - a takeover command (/new /cwd /fork /resume) stops the running turn,
468
+ // then executes via its handler below (/stop is handled above);
469
+ // - plain text is injected into the running turn via `turn/steer`.
470
+ const inflight = this.inFlight.get(sessionKey);
471
+ if (inflight) {
472
+ if (command && LARK_TAKEOVER_COMMANDS.has(command.name)) {
473
+ if (this.interruptSession(sessionKey)) {
474
+ this.logger.log({
475
+ event: "session.stopped",
476
+ tenant_key: event.tenant_key ?? event.sender.tenant_key,
477
+ chat_id: event.message.chat_id,
478
+ thread_id: event.message.thread_id,
479
+ message_id: event.message.message_id,
480
+ session_key: sessionKey,
481
+ provider: this.provider,
482
+ source: "command_takeover",
483
+ command: command.name,
484
+ });
485
+ }
486
+ }
487
+ else if (!command && prompt) {
488
+ if (inflight.steer.send(prompt)) {
489
+ await this.tryCreateReaction(event, sessionKey, LARK_STEER_ACK_REACTION);
490
+ this.logger.log({
491
+ event: "session.steered",
492
+ tenant_key: event.tenant_key ?? event.sender.tenant_key,
493
+ chat_id: event.message.chat_id,
494
+ thread_id: event.message.thread_id,
495
+ message_id: event.message.message_id,
496
+ session_key: sessionKey,
497
+ provider: this.provider,
498
+ });
499
+ return;
500
+ }
501
+ // Backend can't steer (e.g. the `codex exec` fallback never subscribes)
502
+ // → fall through to the queue so the message runs as the next turn.
503
+ }
504
+ }
505
+ // Informational commands (/help, /models, /search) answer instantly and
506
+ // also bypass the queue so they respond even while a turn is running.
507
+ if (command && LARK_INFO_COMMANDS.has(command.name)) {
508
+ await this.handleInfoCommand(event, sessionKey, command);
509
+ return;
510
+ }
511
+ // Action commands (/goal, /diff) operate on the current session / working
512
+ // tree without running a Codex turn; also bypass the queue.
513
+ if (command && LARK_ACTION_COMMANDS.has(command.name)) {
514
+ await this.handleActionCommand(event, sessionKey, command);
515
+ return;
516
+ }
517
+ // /resume lists / navigates the user's recent conversations; bypasses queue.
518
+ if (command && command.name === "resume") {
519
+ await this.handleResumeCommand(event, sessionKey, command);
520
+ return;
521
+ }
522
+ // /fork spins the conversation off into a new group/thread; bypasses queue.
523
+ if (command && command.name === "fork") {
524
+ await this.handleForkCommand(event, sessionKey);
525
+ return;
526
+ }
527
+ // /cwd binds the conversation's working directory; bypasses queue.
528
+ if (command && command.name === "cwd") {
529
+ await this.handleCwdCommand(event, sessionKey, command);
530
+ return;
531
+ }
532
+ // /runtime and /model open the interactive settings card; bypass queue.
533
+ if (command && (command.name === "runtime" || command.name === "model")) {
534
+ await this.handleSettingsCommand(event, sessionKey, command.name === "runtime");
535
+ return;
536
+ }
537
+ // /agent lists declarative agents or selects one for this conversation.
538
+ if (command && command.name === "agent") {
539
+ await this.handleAgentCommand(event, sessionKey, command);
540
+ return;
541
+ }
542
+ await this.queue.enqueue(sessionKey, async () => {
543
+ if (isLarkNewSessionCommand(prompt)) {
544
+ await this.handleNewSessionCommand(event, sessionKey);
545
+ return;
546
+ }
547
+ // Best-effort, off the turn's critical path: the directory only feeds
548
+ // /resume, so a lagging or failed write must never delay the reply.
549
+ void this.recordConversation(event, sessionKey, prompt);
550
+ const session = await this.activeSessionStore.ensure(sessionKey);
551
+ if (session.source !== "existing") {
552
+ this.logger.log({
553
+ event: "session.started",
554
+ tenant_key: event.tenant_key ?? event.sender.tenant_key,
555
+ chat_id: event.message.chat_id,
556
+ thread_id: event.message.thread_id,
557
+ message_id: event.message.message_id,
558
+ session_key: sessionKey,
559
+ session_id: session.sessionId,
560
+ provider: this.provider,
561
+ });
562
+ }
563
+ // Register the session's identity row (idempotent): the first turn fixes
564
+ // its source + title; rotated (`/new`) and forked sessions land here on
565
+ // their first turn too, so the control plane lists every Lark session.
566
+ this.registerSession(session.sessionId, prompt);
567
+ this.logMessageReceived(event, sessionKey, session.sessionId);
568
+ const startedReactionId = await this.tryCreateReaction(event, sessionKey, LARK_WORK_STARTED_REACTION, session.sessionId);
569
+ const reply = await this.buildReply(event, sessionKey, session.sessionId, prompt, session.cwd ?? this.cwd, await this.resolveTurnExecution(sessionKey, session));
570
+ if (reply.text) {
571
+ const sent = await this.sendReplyText(event, reply.text);
572
+ if (sent) {
573
+ this.logger.log({
574
+ event: "message.completed",
575
+ tenant_key: event.tenant_key ?? event.sender.tenant_key,
576
+ chat_id: event.message.chat_id,
577
+ thread_id: event.message.thread_id,
578
+ message_id: event.message.message_id,
579
+ session_key: sessionKey,
580
+ session_id: session.sessionId,
581
+ provider: reply.provider,
582
+ model: reply.model,
583
+ });
584
+ }
585
+ }
586
+ await this.tryCreateReaction(event, sessionKey, LARK_WORK_COMPLETED_REACTION, session.sessionId);
587
+ await this.tryDeleteReaction(event, sessionKey, startedReactionId, LARK_WORK_STARTED_REACTION, session.sessionId);
588
+ });
589
+ }
590
+ async handleStopCommand(event, sessionKey) {
591
+ const aborted = this.interruptSession(sessionKey);
592
+ this.logger.log({
593
+ event: aborted ? "session.stopped" : "session.stop_noop",
594
+ tenant_key: event.tenant_key ?? event.sender.tenant_key,
595
+ chat_id: event.message.chat_id,
596
+ thread_id: event.message.thread_id,
597
+ message_id: event.message.message_id,
598
+ session_key: sessionKey,
599
+ provider: this.provider,
600
+ });
601
+ await this.sendReplyText(event, aborted ? DEFAULT_STOP_ACK_RESPONSE : DEFAULT_STOP_NO_INFLIGHT_RESPONSE);
602
+ }
603
+ async handleInfoCommand(event, sessionKey, command) {
604
+ const session = await this.activeSessionStore.get(sessionKey);
605
+ const runtime = session?.runtime ?? this.provider;
606
+ let text;
607
+ switch (command.name) {
608
+ case "models":
609
+ text = await this.renderModelsReply(runtime);
610
+ break;
611
+ case "help":
612
+ default:
613
+ text = LARK_HELP_TEXT;
614
+ break;
615
+ }
616
+ await this.sendReplyText(event, text);
617
+ this.logger.log({
618
+ event: "command.handled",
619
+ tenant_key: event.tenant_key ?? event.sender.tenant_key,
620
+ chat_id: event.message.chat_id,
621
+ thread_id: event.message.thread_id,
622
+ message_id: event.message.message_id,
623
+ session_key: sessionKey,
624
+ command: command.name,
625
+ provider: this.provider,
626
+ });
627
+ }
628
+ async handleActionCommand(event, sessionKey, command) {
629
+ if (command.name === "diff") {
630
+ await this.handleDiffCommand(event, sessionKey);
631
+ }
632
+ else {
633
+ const text = command.name === "goal"
634
+ ? await this.renderGoalReply(sessionKey, command.args)
635
+ : LARK_HELP_TEXT;
636
+ await this.sendReplyText(event, text);
637
+ }
638
+ this.logger.log({
639
+ event: "command.handled",
640
+ tenant_key: event.tenant_key ?? event.sender.tenant_key,
641
+ chat_id: event.message.chat_id,
642
+ thread_id: event.message.thread_id,
643
+ message_id: event.message.message_id,
644
+ session_key: sessionKey,
645
+ command: command.name,
646
+ provider: this.provider,
647
+ });
648
+ }
649
+ /**
650
+ * Render the working-tree diff as a CardKit card (summary + per-file stats +
651
+ * a collapsed full diff). Falls back to a plain-text reply when the sender
652
+ * has no card capability or the card send fails.
653
+ */
654
+ async handleDiffCommand(event, sessionKey) {
655
+ const result = await this.collectGitDiff();
656
+ if (result.kind === "error") {
657
+ await this.sendReplyText(event, `git diff 失败:${result.message}`);
658
+ return;
659
+ }
660
+ if (result.kind === "clean") {
661
+ await this.sendReplyText(event, "工作区干净,没有改动。");
662
+ return;
663
+ }
664
+ if (this.messageSender.createCard) {
665
+ try {
666
+ await this.sendStandaloneCard(event, buildDiffCardJson(result.input));
667
+ return;
668
+ }
669
+ catch (error) {
670
+ this.logger.log({
671
+ event: "command.diff_card_failed",
672
+ chat_id: event.message.chat_id,
673
+ thread_id: event.message.thread_id,
674
+ message_id: event.message.message_id,
675
+ session_key: sessionKey,
676
+ error: toErrorMessage(error),
677
+ });
678
+ }
679
+ }
680
+ await this.sendReplyText(event, result.fallbackText);
681
+ }
682
+ async collectGitDiff() {
683
+ const cwd = this.cwd ?? process.cwd();
684
+ try {
685
+ const [{ stdout: diff }, { stdout: numstat }, { stdout: untracked }] = await Promise.all([
686
+ execFileAsync("git", ["-C", cwd, "diff"], { maxBuffer: 8 * 1024 * 1024 }),
687
+ execFileAsync("git", ["-C", cwd, "diff", "--numstat"], { maxBuffer: 8 * 1024 * 1024 }),
688
+ execFileAsync("git", ["-C", cwd, "ls-files", "--others", "--exclude-standard"], {
689
+ maxBuffer: 8 * 1024 * 1024,
690
+ }),
691
+ ]);
692
+ const files = [];
693
+ let totalAdditions = 0;
694
+ let totalDeletions = 0;
695
+ for (const line of numstat.split("\n")) {
696
+ if (!line.trim()) {
697
+ continue;
698
+ }
699
+ const [addRaw, delRaw, ...rest] = line.split("\t");
700
+ const filePath = rest.join("\t").trim();
701
+ if (!filePath) {
702
+ continue;
703
+ }
704
+ const additions = addRaw === "-" ? null : Number(addRaw);
705
+ const deletions = delRaw === "-" ? null : Number(delRaw);
706
+ if (additions != null && Number.isFinite(additions)) {
707
+ totalAdditions += additions;
708
+ }
709
+ if (deletions != null && Number.isFinite(deletions)) {
710
+ totalDeletions += deletions;
711
+ }
712
+ files.push({ path: filePath, additions, deletions });
713
+ }
714
+ const untrackedFiles = untracked
715
+ .split("\n")
716
+ .map((file) => file.trim())
717
+ .filter(Boolean);
718
+ for (const filePath of untrackedFiles) {
719
+ files.push({ path: filePath, additions: null, deletions: null, untracked: true });
720
+ }
721
+ if (files.length === 0 && !diff.trim()) {
722
+ return { kind: "clean" };
723
+ }
724
+ const overCardLimit = Buffer.byteLength(diff, "utf8") > DIFF_CARD_MAX_BYTES;
725
+ const diffBody = overCardLimit ? diff.slice(0, DIFF_CARD_MAX_BYTES) : diff.trimEnd();
726
+ // Plain-text fallback mirrors the previous /diff output.
727
+ let body = diff.trimEnd();
728
+ if (untrackedFiles.length) {
729
+ const list = untrackedFiles.map((file) => `?? ${file}`).join("\n");
730
+ body = body ? `${body}\n\n未跟踪文件:\n${list}` : `未跟踪文件:\n${list}`;
731
+ }
732
+ const fallbackText = Buffer.byteLength(body, "utf8") > DIFF_REPLY_MAX_BYTES
733
+ ? `${body.slice(0, DIFF_REPLY_MAX_BYTES)}\n… (diff 过长已截断)`
734
+ : body;
735
+ return {
736
+ kind: "diff",
737
+ input: { files, totalAdditions, totalDeletions, diffBody, truncated: overCardLimit },
738
+ fallbackText,
739
+ };
740
+ }
741
+ catch (error) {
742
+ return { kind: "error", message: toErrorMessage(error) };
743
+ }
744
+ }
745
+ async renderGoalReply(sessionKey, args) {
746
+ if (!this.capabilities) {
747
+ return LARK_BACKEND_UNSUPPORTED;
748
+ }
749
+ const session = await this.activeSessionStore.get(sessionKey);
750
+ if (!session) {
751
+ return LARK_NO_ACTIVE_SESSION;
752
+ }
753
+ const sub = args.trim();
754
+ if (!sub) {
755
+ const result = await this.capabilities.getGoal(session.sessionId);
756
+ if (!result.ok) {
757
+ return this.capabilityErrorText(result);
758
+ }
759
+ const goal = result.data;
760
+ if (!goal?.objective) {
761
+ return "当前没有设置目标。用 `/goal <目标>` 设置。";
762
+ }
763
+ const status = goal.status ? `(${goal.status})` : "";
764
+ return `当前目标:${goal.objective}${status}`;
765
+ }
766
+ if (sub === "clear") {
767
+ const result = await this.capabilities.clearGoal(session.sessionId);
768
+ return result.ok ? "已清除当前会话目标。" : this.capabilityErrorText(result);
769
+ }
770
+ const result = await this.capabilities.setGoal(session.sessionId, sub);
771
+ return result.ok ? `已设置目标:${sub}` : this.capabilityErrorText(result);
772
+ }
773
+ capabilityErrorText(result) {
774
+ switch (result.reason) {
775
+ case "no_active_thread":
776
+ return LARK_NO_ACTIVE_SESSION;
777
+ case "unsupported":
778
+ return LARK_BACKEND_UNSUPPORTED;
779
+ default:
780
+ return `操作失败:${result.message ?? "未知错误"}`;
781
+ }
782
+ }
783
+ conversationKindOf(event) {
784
+ if (event.message.thread_id) {
785
+ return "thread";
786
+ }
787
+ return event.message.chat_type === "group" ? "group" : "p2p";
788
+ }
789
+ async recordConversation(event, sessionKey, prompt) {
790
+ const title = prompt.trim();
791
+ if (!title) {
792
+ return;
793
+ }
794
+ try {
795
+ await this.conversationDirectory.record({
796
+ conversationKey: sessionKey,
797
+ chatId: event.message.chat_id,
798
+ threadId: event.message.thread_id,
799
+ kind: this.conversationKindOf(event),
800
+ title,
801
+ participantOpenId: event.sender.sender_id?.open_id,
802
+ });
803
+ }
804
+ catch (error) {
805
+ this.logger.log({
806
+ event: "conversation.record_failed",
807
+ session_key: sessionKey,
808
+ message_id: event.message.message_id,
809
+ error: toErrorMessage(error),
810
+ });
811
+ }
812
+ }
813
+ async handleCwdCommand(event, sessionKey, command) {
814
+ const session = await this.activeSessionStore.get(sessionKey);
815
+ const currentCwd = session?.cwd ?? this.cwd;
816
+ const arg = command.args.trim();
817
+ if (!arg) {
818
+ await this.sendReplyText(event, currentCwd ? `当前工作目录:${currentCwd}` : "当前使用默认工作目录。");
819
+ return;
820
+ }
821
+ const base = currentCwd ?? this.allowedRoots[0] ?? process.cwd();
822
+ const resolved = path.isAbsolute(arg) ? path.resolve(arg) : path.resolve(base, arg);
823
+ if (!findInnermostAllowedRoot(resolved, this.allowedRoots)) {
824
+ await this.sendReplyText(event, `目录不在允许范围内(需位于 AGENT_ALLOWED_DIRS 内):${resolved}`);
825
+ return;
826
+ }
827
+ try {
828
+ await mkdir(resolved, { recursive: true });
829
+ }
830
+ catch (error) {
831
+ await this.sendReplyText(event, `创建目录失败:${toErrorMessage(error)}`);
832
+ return;
833
+ }
834
+ await this.activeSessionStore.bindCwd(sessionKey, resolved);
835
+ await this.sendReplyText(event, `已绑定工作目录:${resolved},并开启新会话。`);
836
+ this.logger.log({
837
+ event: "command.handled",
838
+ tenant_key: event.tenant_key ?? event.sender.tenant_key,
839
+ chat_id: event.message.chat_id,
840
+ thread_id: event.message.thread_id,
841
+ message_id: event.message.message_id,
842
+ session_key: sessionKey,
843
+ command: "cwd",
844
+ provider: this.provider,
845
+ });
846
+ }
847
+ async handleResumeCommand(event, sessionKey, command) {
848
+ const arg = command.args.trim();
849
+ if (arg) {
850
+ await this.resumeByShortId(event, arg);
851
+ }
852
+ else {
853
+ await this.resumeList(event);
854
+ }
855
+ this.logger.log({
856
+ event: "command.handled",
857
+ tenant_key: event.tenant_key ?? event.sender.tenant_key,
858
+ chat_id: event.message.chat_id,
859
+ thread_id: event.message.thread_id,
860
+ message_id: event.message.message_id,
861
+ session_key: sessionKey,
862
+ command: "resume",
863
+ provider: this.provider,
864
+ });
865
+ }
866
+ async resumeList(event) {
867
+ const openId = event.sender.sender_id?.open_id;
868
+ if (!openId) {
869
+ await this.sendReplyText(event, "无法识别你的身份,暂时无法列出历史会话。");
870
+ return;
871
+ }
872
+ const records = await this.conversationDirectory.listForUser(openId, 5);
873
+ if (records.length === 0) {
874
+ await this.sendReplyText(event, "你还没有可恢复的历史会话。");
875
+ return;
876
+ }
877
+ if (this.canUseCardSender()) {
878
+ try {
879
+ await this.sendStandaloneCard(event, buildResumeCardJson(records, {
880
+ chatId: event.message.chat_id,
881
+ threadId: event.message.thread_id,
882
+ }));
883
+ return;
884
+ }
885
+ catch (error) {
886
+ this.logger.log({
887
+ event: "resume.card_failed",
888
+ message_id: event.message.message_id,
889
+ error: toErrorMessage(error),
890
+ });
891
+ // fall through to text
892
+ }
893
+ }
894
+ const lines = records.map((record, index) => {
895
+ if (record.kind === "thread") {
896
+ return `${index + 1}. ${record.title} \`${record.shortId}\`(话题,发送 /resume ${record.shortId} 转发到此)`;
897
+ }
898
+ return `${index + 1}. ${record.title} \`${record.shortId}\` ${buildChatApplink(record.chatId)}`;
899
+ });
900
+ await this.sendReplyText(event, ["最近会话:", ...lines].join("\n"));
901
+ }
902
+ async resumeByShortId(event, shortId) {
903
+ const record = await this.conversationDirectory.getByShortId(shortId);
904
+ if (!record) {
905
+ await this.sendReplyText(event, `没有找到 id 为 \`${shortId}\` 的会话。`);
906
+ return;
907
+ }
908
+ if (record.kind === "thread" && record.threadId) {
909
+ if (!this.messageSender.forwardThread) {
910
+ await this.sendReplyText(event, "当前不支持转发话题。");
911
+ return;
912
+ }
913
+ const target = this.forwardTargetOf(event);
914
+ try {
915
+ await this.messageSender.forwardThread({
916
+ threadId: record.threadId,
917
+ receiveId: target.receiveId,
918
+ receiveIdType: target.receiveIdType,
919
+ uuid: randomUUID(),
920
+ });
921
+ await this.sendReplyText(event, `已把话题「${record.title}」转发到当前会话。`);
922
+ }
923
+ catch (error) {
924
+ await this.sendReplyText(event, `转发失败:${toErrorMessage(error)}`);
925
+ }
926
+ return;
927
+ }
928
+ await this.sendReplyText(event, `会话「${record.title}」:${buildChatApplink(record.chatId)}`);
929
+ }
930
+ async sendStandaloneCard(event, cardJson) {
931
+ const sender = this.messageSender;
932
+ if (!sender.createCard) {
933
+ throw new Error("card sender unavailable");
934
+ }
935
+ const { cardId } = await sender.createCard({ cardJson });
936
+ const uuid = randomUUID();
937
+ if (event.message.thread_id && sender.replyInteractive) {
938
+ const { messageId } = await sender.replyInteractive({
939
+ messageId: event.message.message_id,
940
+ cardId,
941
+ uuid,
942
+ replyInThread: true,
943
+ });
944
+ return { cardId, messageId };
945
+ }
946
+ if (sender.sendInteractive) {
947
+ const { messageId } = await sender.sendInteractive({
948
+ chatId: event.message.chat_id,
949
+ cardId,
950
+ uuid,
951
+ });
952
+ return { cardId, messageId };
953
+ }
954
+ return { cardId };
955
+ }
956
+ async handleForkCommand(event, sessionKey) {
957
+ const sender = this.messageSender;
958
+ if (!this.capabilities?.forkSession ||
959
+ !sender.sendText ||
960
+ !sender.listMessages ||
961
+ !sender.listChatMembers) {
962
+ await this.sendReplyText(event, "当前不支持 /fork(仅 app_server 后端 + 飞书机器人能力)。");
963
+ return;
964
+ }
965
+ const session = await this.activeSessionStore.get(sessionKey);
966
+ if (!session) {
967
+ await this.sendReplyText(event, LARK_NO_ACTIVE_SESSION);
968
+ return;
969
+ }
970
+ const tenantKey = event.tenant_key ?? event.sender.tenant_key ?? "unknown-tenant";
971
+ const isThread = Boolean(event.message.thread_id);
972
+ const directoryRecord = await this.conversationDirectory.get(sessionKey);
973
+ // Prefer the real Feishu chat name; fall back to the last user message, then
974
+ // a sensible default. (p2p chats have no name, so the fallback applies.)
975
+ let chatName = "";
976
+ if (sender.getChat) {
977
+ try {
978
+ chatName = (await sender.getChat({ chatId: event.message.chat_id })).name;
979
+ }
980
+ catch (error) {
981
+ this.logger.log({ event: "fork.get_chat_failed", error: toErrorMessage(error), message_id: event.message.message_id });
982
+ }
983
+ }
984
+ const baseName = chatName.trim() || directoryRecord?.title?.trim() || "Codex 会话";
985
+ // Members serve both the transcript (id → name) and, for the group case,
986
+ // the new group's member list. Fetch once, best-effort.
987
+ let members = [];
988
+ try {
989
+ members = await sender.listChatMembers({ chatId: event.message.chat_id });
990
+ }
991
+ catch (error) {
992
+ this.logger.log({ event: "fork.members_failed", error: toErrorMessage(error), message_id: event.message.message_id });
993
+ }
994
+ const nameById = new Map(members.map((m) => [m.memberId, m.name]));
995
+ let transcript = "";
996
+ try {
997
+ const messages = await sender.listMessages({
998
+ containerIdType: isThread ? "thread" : "chat",
999
+ containerId: isThread ? event.message.thread_id : event.message.chat_id,
1000
+ pageSize: 50,
1001
+ });
1002
+ transcript = buildForkTranscript(messages, nameById);
1003
+ }
1004
+ catch (error) {
1005
+ this.logger.log({ event: "fork.transcript_failed", error: toErrorMessage(error), message_id: event.message.message_id });
1006
+ }
1007
+ const header = `📋 原会话「${baseName}」记录(fork):`;
1008
+ const body = transcript ? `${header}\n${transcript}` : header;
1009
+ let newSessionKey;
1010
+ let newChatId = null;
1011
+ try {
1012
+ if (isThread) {
1013
+ const sent = await sender.sendText({
1014
+ chatId: event.message.chat_id,
1015
+ text: body,
1016
+ uuid: randomUUID(),
1017
+ });
1018
+ if (!sent.threadId) {
1019
+ await this.sendReplyText(event, "fork 失败:未能创建新话题。");
1020
+ return;
1021
+ }
1022
+ newSessionKey = `lark:${tenantKey}:thread:${sent.threadId}`;
1023
+ }
1024
+ else {
1025
+ if (!sender.createChat) {
1026
+ await this.sendReplyText(event, "当前不支持建群。");
1027
+ return;
1028
+ }
1029
+ const userIdList = members.map((m) => m.memberId).filter(Boolean);
1030
+ const created = await sender.createChat({
1031
+ name: `${baseName} - fork`,
1032
+ userIdList,
1033
+ uuid: randomUUID(),
1034
+ });
1035
+ await sender.sendText({ chatId: created.chatId, text: body, uuid: randomUUID() });
1036
+ newSessionKey = `lark:${tenantKey}:chat:${created.chatId}`;
1037
+ newChatId = created.chatId;
1038
+ }
1039
+ }
1040
+ catch (error) {
1041
+ await this.sendReplyText(event, `fork 失败:${toErrorMessage(error)}`);
1042
+ return;
1043
+ }
1044
+ // Bind the new conversation to a forked Codex thread carrying context.
1045
+ // Carry the source cwd so the forked thread's working dir stays consistent.
1046
+ // The session id is opaque (no routing baked in); newSessionKey is the
1047
+ // routing key the store maps to it.
1048
+ if (!this.sessionProvisioner) {
1049
+ await this.sendReplyText(event, LARK_BACKEND_UNSUPPORTED);
1050
+ return;
1051
+ }
1052
+ let newLocalThreadId;
1053
+ let forkResult;
1054
+ try {
1055
+ newLocalThreadId = await this.sessionProvisioner({
1056
+ sessionKey: newSessionKey,
1057
+ fromSessionId: session.sessionId,
1058
+ });
1059
+ await this.activeSessionStore.set(newSessionKey, newLocalThreadId, session.cwd);
1060
+ forkResult = await this.capabilities.forkSession(session.sessionId, newLocalThreadId);
1061
+ }
1062
+ catch (error) {
1063
+ await this.sendReplyText(event, `fork 会话创建失败:${toErrorMessage(error)}`);
1064
+ return;
1065
+ }
1066
+ const contextNote = forkResult.ok
1067
+ ? "已带上当前会话上下文。"
1068
+ : `但未能带上上下文:${this.capabilityErrorText(forkResult)}`;
1069
+ // Surface a one-tap entry into the new conversation in the current chat.
1070
+ if (newChatId) {
1071
+ const entryCardJson = buildOpenChatCardJson(newChatId, `${baseName} - fork`);
1072
+ let sentCard = false;
1073
+ if (this.canUseCardSender()) {
1074
+ try {
1075
+ await this.sendStandaloneCard(event, entryCardJson);
1076
+ sentCard = true;
1077
+ }
1078
+ catch (error) {
1079
+ this.logger.log({ event: "fork.entry_card_failed", error: toErrorMessage(error), message_id: event.message.message_id });
1080
+ }
1081
+ }
1082
+ if (!sentCard) {
1083
+ await this.sendReplyText(event, `已 fork 到新群「${baseName} - fork」,点此进入:${buildChatApplink(newChatId)}(${contextNote})`);
1084
+ }
1085
+ else if (!forkResult.ok) {
1086
+ await this.sendReplyText(event, `提示:${contextNote}`);
1087
+ }
1088
+ }
1089
+ else {
1090
+ // Topic thread: the root message is already visible in this group.
1091
+ await this.sendReplyText(event, `已在本群新建话题(见上方新消息),可在其中继续对话。${contextNote}`);
1092
+ }
1093
+ this.logger.log({
1094
+ event: "command.handled",
1095
+ tenant_key: tenantKey,
1096
+ chat_id: event.message.chat_id,
1097
+ thread_id: event.message.thread_id,
1098
+ message_id: event.message.message_id,
1099
+ session_key: sessionKey,
1100
+ command: "fork",
1101
+ provider: this.provider,
1102
+ });
1103
+ }
1104
+ /**
1105
+ * Send the `/runtime` (or `/model`) settings card and seed the pending
1106
+ * selection state with the conversation's current settings.
1107
+ */
1108
+ async handleSettingsCommand(event, sessionKey, includeRuntime) {
1109
+ const session = await this.activeSessionStore.get(sessionKey);
1110
+ const currentRuntime = session?.runtime ?? this.provider;
1111
+ const currentModel = session?.model;
1112
+ const currentEffort = session?.reasoningEffort;
1113
+ if (!this.canUseCardSender()) {
1114
+ await this.sendReplyText(event, this.renderSettingsSummary(currentRuntime, currentModel, currentEffort));
1115
+ return;
1116
+ }
1117
+ const models = await this.fetchModelOptions(currentRuntime);
1118
+ const pending = {
1119
+ includeRuntime,
1120
+ currentRuntime,
1121
+ currentModel,
1122
+ runtime: currentRuntime,
1123
+ model: currentModel ?? SETTINGS_DEFAULT_VALUE,
1124
+ effort: currentEffort ?? SETTINGS_DEFAULT_VALUE,
1125
+ models,
1126
+ };
1127
+ this.pendingSettings.set(sessionKey, pending);
1128
+ const cardJson = buildSettingsCardJson(this.settingsCardInput(sessionKey, pending));
1129
+ try {
1130
+ await this.sendStandaloneCard(event, cardJson);
1131
+ }
1132
+ catch (error) {
1133
+ this.pendingSettings.delete(sessionKey);
1134
+ await this.sendReplyText(event, `发送设置卡片失败:${toErrorMessage(error)}`);
1135
+ return;
1136
+ }
1137
+ this.logger.log({
1138
+ event: "command.handled",
1139
+ tenant_key: event.tenant_key ?? event.sender.tenant_key,
1140
+ chat_id: event.message.chat_id,
1141
+ thread_id: event.message.thread_id,
1142
+ message_id: event.message.message_id,
1143
+ session_key: sessionKey,
1144
+ command: includeRuntime ? "runtime" : "model",
1145
+ provider: currentRuntime,
1146
+ });
1147
+ }
1148
+ /**
1149
+ * `/agent` — list declarative agents, or select one for this conversation.
1150
+ * Selecting rotates to a fresh session (so sandbox/persona/tools don't change
1151
+ * mid-thread) and drops explicit runtime/model overrides so the agent drives them.
1152
+ */
1153
+ async handleAgentCommand(event, sessionKey, command) {
1154
+ const name = command.args.trim();
1155
+ const session = await this.activeSessionStore.get(sessionKey);
1156
+ const current = session?.agent;
1157
+ if (!name) {
1158
+ if (!this.agentCatalog) {
1159
+ await this.sendReplyText(event, "当前运行环境不支持 agent 目录。");
1160
+ return;
1161
+ }
1162
+ let specs;
1163
+ try {
1164
+ specs = await this.agentCatalog.list();
1165
+ }
1166
+ catch (error) {
1167
+ await this.sendReplyText(event, `读取 agent 失败:${toErrorMessage(error)}`);
1168
+ return;
1169
+ }
1170
+ const lines = ["可用 agent:"];
1171
+ if (specs.length === 0) {
1172
+ lines.push(" (暂无;用 `rynx agent add <名称>` 创建)");
1173
+ }
1174
+ else {
1175
+ for (const spec of specs) {
1176
+ const marker = spec.id === current ? "✓" : "·";
1177
+ const runtime = spec.runtime ?? "默认";
1178
+ const model = spec.model ?? "默认";
1179
+ const display = spec.name && spec.name !== spec.id ? `${spec.name} · ` : "";
1180
+ lines.push(` ${marker} ${spec.id}(${display}${runtime} / ${model})`);
1181
+ }
1182
+ }
1183
+ lines.push(current ? `\n当前:${current}` : "\n当前:未选择(用全局默认)");
1184
+ lines.push("用 `/agent <名称>` 切换。");
1185
+ await this.sendReplyText(event, lines.join("\n"));
1186
+ return;
1187
+ }
1188
+ if (!this.agentCatalog) {
1189
+ await this.sendReplyText(event, "当前运行环境不支持 agent 目录。");
1190
+ return;
1191
+ }
1192
+ let spec;
1193
+ try {
1194
+ spec = await this.agentCatalog.get(name);
1195
+ }
1196
+ catch (error) {
1197
+ await this.sendReplyText(event, `agent「${name}」配置无效:${toErrorMessage(error)}`);
1198
+ return;
1199
+ }
1200
+ if (!spec) {
1201
+ await this.sendReplyText(event, `未找到 agent「${name}」。用 /agent 查看可用列表。`);
1202
+ return;
1203
+ }
1204
+ await this.activeSessionStore.setAgent(sessionKey, name);
1205
+ const runtime = spec.runtime ?? "默认运行时";
1206
+ const model = spec.model ?? "默认模型";
1207
+ await this.sendReplyText(event, `已切换到 agent「${name}」(${runtime} / ${model}),并开启新会话。`);
1208
+ this.logger.log({
1209
+ event: "command.handled",
1210
+ tenant_key: event.tenant_key ?? event.sender.tenant_key,
1211
+ chat_id: event.message.chat_id,
1212
+ thread_id: event.message.thread_id,
1213
+ message_id: event.message.message_id,
1214
+ session_key: sessionKey,
1215
+ command: "agent",
1216
+ provider: spec.runtime ?? this.provider,
1217
+ });
1218
+ }
1219
+ /**
1220
+ * Label shown for the model in the turn card. An empty model means the
1221
+ * runtime follows its own configured default (e.g. traex with no
1222
+ * `TRAEX_MODEL`); show a placeholder so the status row isn't blank (the
1223
+ * runtime itself is shown separately).
1224
+ */
1225
+ displayModel(model) {
1226
+ return model?.trim() || "默认模型";
1227
+ }
1228
+ renderSettingsSummary(runtime, model, effort) {
1229
+ return [
1230
+ "当前会话设置:",
1231
+ `• 运行时:${getRuntimeProfile(runtime).displayName}`,
1232
+ `• 模型:${model ?? "(运行时默认)"}`,
1233
+ `• 思考强度:${effort ?? "(默认)"}`,
1234
+ "(当前环境不支持交互卡片,无法在此切换。)",
1235
+ ].join("\n");
1236
+ }
1237
+ /** Assemble the settings-card input from the current pending selection. */
1238
+ settingsCardInput(sessionKey, pending) {
1239
+ return {
1240
+ sessionKey,
1241
+ includeRuntime: pending.includeRuntime,
1242
+ runtimeOptions: Object.keys(RUNTIME_PROFILES).map((id) => ({
1243
+ value: id,
1244
+ label: RUNTIME_PROFILES[id].displayName,
1245
+ })),
1246
+ models: pending.models,
1247
+ selectedRuntime: isAgentRuntimeId(pending.runtime) ? pending.runtime : pending.currentRuntime,
1248
+ selectedModel: pending.model,
1249
+ selectedEffort: pending.effort,
1250
+ };
1251
+ }
1252
+ async fetchModelOptions(runtime) {
1253
+ if (!this.capabilities) {
1254
+ return [];
1255
+ }
1256
+ try {
1257
+ const response = await this.capabilities.listModels(runtime);
1258
+ const models = (response?.data ?? []).filter((model) => !model.hidden);
1259
+ return models.map((model) => ({
1260
+ value: String(model.model || model.id),
1261
+ label: String(model.displayName || model.model || model.id),
1262
+ isDefault: Boolean(model.isDefault),
1263
+ supportedEfforts: extractSupportedEfforts(model),
1264
+ }));
1265
+ }
1266
+ catch (error) {
1267
+ this.logger.log({ event: "settings.models_failed", error: toErrorMessage(error) });
1268
+ return [];
1269
+ }
1270
+ }
1271
+ /**
1272
+ * Handle a settings-card dropdown change. A runtime change re-scopes the
1273
+ * model list (and resets model/effort); a model change re-scopes the effort
1274
+ * list — both re-render the card in place via the callback response so the
1275
+ * dependent dropdowns update. Effort changes just update pending state.
1276
+ */
1277
+ async handleSettingsSelect(event) {
1278
+ const value = event.value;
1279
+ const sessionKey = typeof value?.sessionKey === "string" ? value.sessionKey : null;
1280
+ const field = typeof value?.field === "string" ? value.field : null;
1281
+ const option = typeof event.option === "string" ? event.option : null;
1282
+ if (!sessionKey || !field || option === null) {
1283
+ return {};
1284
+ }
1285
+ const pending = this.pendingSettings.get(sessionKey);
1286
+ if (!pending) {
1287
+ return {};
1288
+ }
1289
+ if (field === "runtime") {
1290
+ if (!isAgentRuntimeId(option) || option === pending.runtime) {
1291
+ return {};
1292
+ }
1293
+ pending.runtime = option;
1294
+ pending.models = await this.fetchModelOptions(option);
1295
+ // A model from the old runtime can't carry over → reset model + effort.
1296
+ pending.model = SETTINGS_DEFAULT_VALUE;
1297
+ pending.effort = SETTINGS_DEFAULT_VALUE;
1298
+ return this.settingsCardResponse(sessionKey, pending);
1299
+ }
1300
+ if (field === "model") {
1301
+ if (option === pending.model) {
1302
+ return {};
1303
+ }
1304
+ pending.model = option;
1305
+ // Available efforts depend on the model → reset the effort selection.
1306
+ pending.effort = SETTINGS_DEFAULT_VALUE;
1307
+ return this.settingsCardResponse(sessionKey, pending);
1308
+ }
1309
+ if (field === "effort") {
1310
+ pending.effort = option;
1311
+ }
1312
+ return {};
1313
+ }
1314
+ /** Card-action response that re-renders the open settings card in place. */
1315
+ settingsCardResponse(sessionKey, pending) {
1316
+ return {
1317
+ card: { type: "raw", data: buildSettingsCard(this.settingsCardInput(sessionKey, pending)) },
1318
+ };
1319
+ }
1320
+ /** Apply the pending settings-card selections to the conversation. */
1321
+ async handleSettingsApply(event) {
1322
+ const value = event.value;
1323
+ const sessionKey = typeof value?.sessionKey === "string" ? value.sessionKey : null;
1324
+ if (!sessionKey) {
1325
+ return { toast: { type: "error", content: "无法识别会话。" } };
1326
+ }
1327
+ const pending = this.pendingSettings.get(sessionKey);
1328
+ if (!pending) {
1329
+ return { toast: { type: "warning", content: "设置已过期,请重新发送 /runtime。" } };
1330
+ }
1331
+ // Pending state is the source of truth — the dropdowns re-render it on each
1332
+ // change, so the selected model is always valid for the selected runtime.
1333
+ const runtime = pending.includeRuntime && isAgentRuntimeId(pending.runtime)
1334
+ ? pending.runtime
1335
+ : pending.currentRuntime;
1336
+ const runtimeChanged = runtime !== pending.currentRuntime;
1337
+ const model = pending.model === SETTINGS_DEFAULT_VALUE ? undefined : pending.model;
1338
+ const selectedModel = pending.models.find((candidate) => candidate.value === pending.model);
1339
+ const allowedEfforts = selectedModel?.supportedEfforts?.length
1340
+ ? selectedModel.supportedEfforts
1341
+ : SETTINGS_EFFORT_VALUES;
1342
+ const effort = pending.effort !== SETTINGS_DEFAULT_VALUE && allowedEfforts.includes(pending.effort)
1343
+ ? pending.effort
1344
+ : undefined;
1345
+ // Only a runtime change rotates to a fresh session (its rollout/transcript
1346
+ // store is runtime-specific). model / effort switch on the SAME thread —
1347
+ // both lineages take them per-turn — so the conversation keeps its context.
1348
+ const rotate = runtimeChanged;
1349
+ await this.activeSessionStore.applySettings(sessionKey, {
1350
+ runtime,
1351
+ model,
1352
+ reasoningEffort: effort,
1353
+ rotate,
1354
+ });
1355
+ this.pendingSettings.delete(sessionKey);
1356
+ const summary = [
1357
+ `运行时:${getRuntimeProfile(runtime).displayName}`,
1358
+ `模型:${model ?? "运行时默认"}`,
1359
+ `思考强度:${effort ?? "默认"}`,
1360
+ ].join(" · ");
1361
+ this.logger.log({
1362
+ event: "settings.applied",
1363
+ session_key: sessionKey,
1364
+ message_id: event.messageId,
1365
+ provider: runtime,
1366
+ model: model ?? undefined,
1367
+ reasoning_effort: effort ?? null,
1368
+ rotated: rotate,
1369
+ });
1370
+ const headline = rotate ? `已应用并开启新会话 — ${summary}` : `已更新 — ${summary}`;
1371
+ // Replace the card via the callback response (atomic — no flash/revert).
1372
+ return this.settingsResultCard(pending.includeRuntime, "green", `✅ ${headline}`);
1373
+ }
1374
+ /** Cancel an open settings card without changing anything. */
1375
+ handleSettingsCancel(event) {
1376
+ const value = event.value;
1377
+ const sessionKey = typeof value?.sessionKey === "string" ? value.sessionKey : null;
1378
+ if (!sessionKey) {
1379
+ return {};
1380
+ }
1381
+ const pending = this.pendingSettings.get(sessionKey);
1382
+ if (!pending) {
1383
+ return {};
1384
+ }
1385
+ const includeRuntime = pending.includeRuntime;
1386
+ this.pendingSettings.delete(sessionKey);
1387
+ return this.settingsResultCard(includeRuntime, "grey", "已取消,设置未改动。");
1388
+ }
1389
+ /**
1390
+ * Build a card-action response that swaps the settings card for a static
1391
+ * result card. Returning the card in the callback response replaces it
1392
+ * atomically; an out-of-band `card.update` would briefly show the result and
1393
+ * then revert to the form.
1394
+ */
1395
+ settingsResultCard(includeRuntime, template, content) {
1396
+ return {
1397
+ card: {
1398
+ type: "raw",
1399
+ data: buildSettingsResultCard({ includeRuntime, template, content }),
1400
+ },
1401
+ };
1402
+ }
1403
+ async renderModelsReply(runtime) {
1404
+ if (!this.capabilities) {
1405
+ return LARK_BACKEND_UNSUPPORTED;
1406
+ }
1407
+ try {
1408
+ const response = await this.capabilities.listModels(runtime);
1409
+ if (!response) {
1410
+ return LARK_BACKEND_UNSUPPORTED;
1411
+ }
1412
+ const models = (response.data ?? []).filter((model) => !model.hidden);
1413
+ if (models.length === 0) {
1414
+ return "未获取到可用模型。";
1415
+ }
1416
+ const lines = models.map((model) => {
1417
+ const label = model.displayName || model.model || model.id;
1418
+ return `• ${label}${model.isDefault ? " ⭐️" : ""}`;
1419
+ });
1420
+ return ["可用模型:", ...lines].join("\n");
1421
+ }
1422
+ catch (error) {
1423
+ return `获取模型列表失败:${toErrorMessage(error)}`;
1424
+ }
1425
+ }
1426
+ async handleNewSessionCommand(event, sessionKey) {
1427
+ const currentSession = await this.activeSessionStore.get(sessionKey);
1428
+ this.logMessageReceived(event, sessionKey, currentSession?.sessionId);
1429
+ const rotated = await this.activeSessionStore.rotate(sessionKey);
1430
+ this.logger.log({
1431
+ event: "session.rotated",
1432
+ tenant_key: event.tenant_key ?? event.sender.tenant_key,
1433
+ chat_id: event.message.chat_id,
1434
+ thread_id: event.message.thread_id,
1435
+ message_id: event.message.message_id,
1436
+ session_key: sessionKey,
1437
+ session_id: rotated.sessionId,
1438
+ previous_session_id: rotated.previousSessionId,
1439
+ provider: this.provider,
1440
+ });
1441
+ const sent = await this.sendReplyText(event, DEFAULT_NEW_SESSION_RESPONSE);
1442
+ if (sent) {
1443
+ this.logger.log({
1444
+ event: "message.completed",
1445
+ tenant_key: event.tenant_key ?? event.sender.tenant_key,
1446
+ chat_id: event.message.chat_id,
1447
+ thread_id: event.message.thread_id,
1448
+ message_id: event.message.message_id,
1449
+ session_key: sessionKey,
1450
+ session_id: rotated.sessionId,
1451
+ provider: this.provider,
1452
+ });
1453
+ }
1454
+ }
1455
+ logMessageReceived(event, sessionKey, sessionId) {
1456
+ this.logger.log({
1457
+ event: "message.received",
1458
+ tenant_key: event.tenant_key ?? event.sender.tenant_key,
1459
+ chat_id: event.message.chat_id,
1460
+ thread_id: event.message.thread_id,
1461
+ message_id: event.message.message_id,
1462
+ session_key: sessionKey,
1463
+ session_id: sessionId,
1464
+ provider: this.provider,
1465
+ });
1466
+ }
1467
+ async tryCreateReaction(event, sessionKey, emojiType, sessionId) {
1468
+ try {
1469
+ return await this.messageSender.createReaction({
1470
+ messageId: event.message.message_id,
1471
+ emojiType,
1472
+ });
1473
+ }
1474
+ catch (error) {
1475
+ this.logger.log({
1476
+ event: "message.reaction_failed",
1477
+ tenant_key: event.tenant_key ?? event.sender.tenant_key,
1478
+ chat_id: event.message.chat_id,
1479
+ thread_id: event.message.thread_id,
1480
+ message_id: event.message.message_id,
1481
+ session_key: sessionKey,
1482
+ session_id: sessionId,
1483
+ emoji_type: emojiType,
1484
+ error: toErrorMessage(error),
1485
+ });
1486
+ return undefined;
1487
+ }
1488
+ }
1489
+ async tryDeleteReaction(event, sessionKey, reactionId, emojiType, sessionId) {
1490
+ if (!reactionId) {
1491
+ return;
1492
+ }
1493
+ try {
1494
+ await this.messageSender.deleteReaction({
1495
+ messageId: event.message.message_id,
1496
+ reactionId,
1497
+ });
1498
+ }
1499
+ catch (error) {
1500
+ this.logger.log({
1501
+ event: "message.reaction_delete_failed",
1502
+ tenant_key: event.tenant_key ?? event.sender.tenant_key,
1503
+ chat_id: event.message.chat_id,
1504
+ thread_id: event.message.thread_id,
1505
+ message_id: event.message.message_id,
1506
+ session_key: sessionKey,
1507
+ session_id: sessionId,
1508
+ emoji_type: emojiType,
1509
+ error: toErrorMessage(error),
1510
+ });
1511
+ }
1512
+ }
1513
+ /**
1514
+ * If this turn replies to a Lark message card, fetch the parent card and wrap
1515
+ * the prompt so the agent receives the card content as context. Generic: it
1516
+ * forwards any interactive (card) parent and names no specific skill — the
1517
+ * agent decides how to act on the card. Returns null when the parent is not a
1518
+ * card (or cannot be fetched), so the caller falls back to the raw prompt.
1519
+ */
1520
+ async tryBuildCardReplyPrompt(event, sessionKey, sessionId, prompt) {
1521
+ const parentMessageId = event.message.parent_id?.trim();
1522
+ if (!parentMessageId || !this.messageSender.getMessage) {
1523
+ return null;
1524
+ }
1525
+ try {
1526
+ const parentMessage = await this.messageSender.getMessage({ messageId: parentMessageId });
1527
+ const content = parentMessage?.body?.content ?? parentMessage?.content;
1528
+ const parentContent = typeof content === "string" && content.trim() ? content : null;
1529
+ if (parentMessage?.msgType !== "interactive" || !parentContent) {
1530
+ return null;
1531
+ }
1532
+ this.logger.log({
1533
+ event: "message.card_reply_detected",
1534
+ tenant_key: event.tenant_key ?? event.sender.tenant_key,
1535
+ chat_id: event.message.chat_id,
1536
+ thread_id: event.message.thread_id,
1537
+ message_id: event.message.message_id,
1538
+ session_key: sessionKey,
1539
+ session_id: sessionId,
1540
+ provider: this.provider,
1541
+ });
1542
+ return buildLarkCardReplyPrompt({
1543
+ userPrompt: prompt,
1544
+ cardContent: parentContent,
1545
+ });
1546
+ }
1547
+ catch (error) {
1548
+ this.logger.log({
1549
+ event: "message.parent_lookup_failed",
1550
+ tenant_key: event.tenant_key ?? event.sender.tenant_key,
1551
+ chat_id: event.message.chat_id,
1552
+ thread_id: event.message.thread_id,
1553
+ message_id: event.message.message_id,
1554
+ session_key: sessionKey,
1555
+ session_id: sessionId,
1556
+ provider: this.provider,
1557
+ error: toErrorMessage(error),
1558
+ });
1559
+ return null;
1560
+ }
1561
+ }
1562
+ /**
1563
+ * Map a session to the {@link TurnSettings} a turn runs with. Already-frozen
1564
+ * sessions are resolved locally; first-turn defaults and named-agent lookup
1565
+ * are delegated to the capability-gated host and then persisted exactly once.
1566
+ */
1567
+ async resolveTurnExecution(sessionKey, session) {
1568
+ if (session.runtime) {
1569
+ return {
1570
+ provider: session.runtime,
1571
+ providerExplicit: true,
1572
+ model: session.model,
1573
+ reasoningEffort: session.reasoningEffort,
1574
+ agentName: session.agent,
1575
+ };
1576
+ }
1577
+ // Lightweight unit-test/custom embeddings may omit the host resolver.
1578
+ if (!this.agentCatalog) {
1579
+ return {
1580
+ provider: session.runtime ?? this.provider,
1581
+ providerExplicit: session.runtime != null,
1582
+ model: session.model,
1583
+ reasoningEffort: session.reasoningEffort,
1584
+ agentName: session.agent ?? this.defaultAgent,
1585
+ };
1586
+ }
1587
+ const plan = await this.agentCatalog.resolveExecution({
1588
+ ...(this.defaultAgent ? { defaultAgent: this.defaultAgent } : {}),
1589
+ session: {
1590
+ ...(session.runtime ? { runtime: session.runtime } : {}),
1591
+ ...(session.model ? { model: session.model } : {}),
1592
+ ...(session.reasoningEffort ? { reasoningEffort: session.reasoningEffort } : {}),
1593
+ ...(session.agent ? { agent: session.agent } : {}),
1594
+ },
1595
+ });
1596
+ // Freshly resolved (not yet frozen) → persist it so it can't drift later.
1597
+ if (!plan.alreadyFrozen) {
1598
+ await this.activeSessionStore.freeze(sessionKey, {
1599
+ runtime: plan.runtime,
1600
+ model: plan.model,
1601
+ reasoningEffort: plan.reasoningEffort,
1602
+ agent: plan.agent,
1603
+ });
1604
+ }
1605
+ return {
1606
+ provider: plan.runtime,
1607
+ providerExplicit: true,
1608
+ model: plan.model,
1609
+ reasoningEffort: plan.reasoningEffort,
1610
+ agentName: plan.agent,
1611
+ };
1612
+ }
1613
+ async buildReply(event, sessionKey, sessionId, prompt, cwd, settings) {
1614
+ if (!["text", "post"].includes(event.message.message_type)) {
1615
+ return {
1616
+ text: DEFAULT_UNSUPPORTED_MESSAGE,
1617
+ provider: settings.provider,
1618
+ };
1619
+ }
1620
+ const runtimePrompt = (await this.tryBuildCardReplyPrompt(event, sessionKey, sessionId, prompt)) ?? prompt;
1621
+ if (!runtimePrompt) {
1622
+ return {
1623
+ text: DEFAULT_EMPTY_MESSAGE,
1624
+ provider: settings.provider,
1625
+ };
1626
+ }
1627
+ const turn = this.registerInFlight(sessionKey);
1628
+ try {
1629
+ if (this.canUseCardSender()) {
1630
+ const streamed = await this.tryBuildReplyWithCard(event, sessionKey, sessionId, runtimePrompt, cwd, turn, settings);
1631
+ if (streamed) {
1632
+ return streamed;
1633
+ }
1634
+ }
1635
+ return await this.buildReplyDrain(event, sessionKey, sessionId, runtimePrompt, cwd, turn, settings);
1636
+ }
1637
+ finally {
1638
+ // Claim the bridge's own rollout lines before releasing the in-flight
1639
+ // gate, so the CLI mirror's cursor sits past them and they're not
1640
+ // mirrored back into Feishu (they were already shown live).
1641
+ if (this.mirror && turn.codexSessionId) {
1642
+ try {
1643
+ await this.mirror.claimToEof(turn.codexSessionId);
1644
+ }
1645
+ catch (error) {
1646
+ this.logger.log({
1647
+ event: "mirror.claim_failed",
1648
+ session_key: sessionKey,
1649
+ session_id: sessionId,
1650
+ error: toErrorMessage(error),
1651
+ });
1652
+ }
1653
+ }
1654
+ this.clearInFlight(sessionKey, turn);
1655
+ }
1656
+ }
1657
+ canUseCardSender() {
1658
+ return Boolean(this.messageSender.createCard &&
1659
+ this.messageSender.batchUpdateCard &&
1660
+ (this.messageSender.sendInteractive || this.messageSender.replyInteractive));
1661
+ }
1662
+ async tryBuildReplyWithCard(event, sessionKey, sessionId, runtimePrompt, cwd, turn, settings) {
1663
+ let started;
1664
+ try {
1665
+ started = await this.conversationRuntime.runLive({
1666
+ message: runtimePrompt,
1667
+ threadId: sessionId,
1668
+ // When an agent is selected but the user hasn't pinned a runtime, pass
1669
+ // undefined so the agent spec's `executor.runtime` decides the backend.
1670
+ provider: settings.providerExplicit || !settings.agentName ? settings.provider : undefined,
1671
+ model: settings.model,
1672
+ reasoningEffort: settings.reasoningEffort,
1673
+ agentName: settings.agentName,
1674
+ cwd,
1675
+ signal: turn.abort.signal,
1676
+ steer: turn.steer,
1677
+ });
1678
+ }
1679
+ catch (error) {
1680
+ const errorMessage = toErrorMessage(error);
1681
+ this.logger.log({
1682
+ event: "message.failed",
1683
+ tenant_key: event.tenant_key ?? event.sender.tenant_key,
1684
+ chat_id: event.message.chat_id,
1685
+ thread_id: event.message.thread_id,
1686
+ message_id: event.message.message_id,
1687
+ session_key: sessionKey,
1688
+ session_id: sessionId,
1689
+ provider: settings.provider,
1690
+ error: errorMessage,
1691
+ });
1692
+ return {
1693
+ text: `处理失败:${errorMessage}`,
1694
+ provider: settings.provider,
1695
+ };
1696
+ }
1697
+ const orchestrator = new LarkSessionCardController({
1698
+ sender: this.messageSender,
1699
+ session: {
1700
+ chatId: event.message.chat_id,
1701
+ messageId: event.message.message_id,
1702
+ threadId: event.message.thread_id,
1703
+ },
1704
+ meta: {
1705
+ runtime: getRuntimeProfile(started.provider).shortLabel,
1706
+ model: this.displayModel(started.model),
1707
+ effort: settings.reasoningEffort ?? null,
1708
+ // Same stable id the conversation directory keys on, so the footer id is
1709
+ // exactly what `/resume <id>` accepts (conversationKey === sessionKey).
1710
+ resumeShortId: deriveConversationShortId(sessionKey),
1711
+ },
1712
+ throttleMs: this.cardThrottleMs,
1713
+ logger: { log: (entry) => this.logger.log({ event: "card.update", ...entry }) },
1714
+ stopButton: { sessionKey },
1715
+ onRuntimeSession: (runtimeSessionId) => this.noteRuntimeSession(turn, runtimeSessionId),
1716
+ });
1717
+ try {
1718
+ // Live co-drive single-writer: `runLive` yields THIS turn's canonical events
1719
+ // off the session bus (the forwarder mirrors them). The server's `onMirror`
1720
+ // already persisted them — do NOT persist again here (that was the double
1721
+ // write). Only render the card. Lark shows the user message inline from the
1722
+ // chat, so keep `session.input.consumed` out of the card render.
1723
+ for await (const sessionEvent of started.generator) {
1724
+ if (sessionEvent.type !== "session.input.consumed") {
1725
+ await orchestrator.ingest(sessionEvent);
1726
+ }
1727
+ }
1728
+ await orchestrator.finalizeAll("completed");
1729
+ }
1730
+ catch (error) {
1731
+ const errorMessage = toErrorMessage(error);
1732
+ const cancelled = error instanceof CodexRuntimeError && error.code === "codex_cancelled";
1733
+ this.logger.log({
1734
+ event: cancelled ? "message.cancelled" : "message.failed",
1735
+ tenant_key: event.tenant_key ?? event.sender.tenant_key,
1736
+ chat_id: event.message.chat_id,
1737
+ thread_id: event.message.thread_id,
1738
+ message_id: event.message.message_id,
1739
+ session_key: sessionKey,
1740
+ session_id: sessionId,
1741
+ provider: this.provider,
1742
+ error: errorMessage,
1743
+ });
1744
+ // The normalizer already emitted `response.failed` (so the card shows the
1745
+ // error); this finalizeAll is a safety net for any still-open card.
1746
+ await orchestrator.finalizeAll(cancelled ? "cancelled" : "failed");
1747
+ return {
1748
+ // A user-triggered cancel needs no redundant chat reply (the card carries it).
1749
+ text: cancelled ? "" : `处理失败:${errorMessage}`,
1750
+ provider: started.provider,
1751
+ model: started.model,
1752
+ };
1753
+ }
1754
+ // The card carries the answer; only emit a plain-text reply when no card
1755
+ // could be posted (e.g. the card API failed), so the chat isn't left empty.
1756
+ return {
1757
+ text: orchestrator.cardStarted ? "" : orchestrator.fallbackText.trim() || DEFAULT_EMPTY_RESPONSE,
1758
+ provider: started.provider,
1759
+ model: started.model,
1760
+ };
1761
+ }
1762
+ /**
1763
+ * On the turn's first `session.status` carrying a runtime session id, bind it
1764
+ * to the in-flight turn and register it with the CLI mirror (so the mirror
1765
+ * watches that rollout file, and the bridge's own lines are later claimed).
1766
+ */
1767
+ noteRuntimeSession(turn, runtimeSessionId) {
1768
+ if (turn.codexSessionId || !runtimeSessionId) {
1769
+ return;
1770
+ }
1771
+ turn.codexSessionId = runtimeSessionId;
1772
+ if (this.mirror) {
1773
+ void this.mirror.noteBoundSession(runtimeSessionId).catch((error) => {
1774
+ this.logger.log({
1775
+ event: "mirror.note_failed",
1776
+ session_key: runtimeSessionId,
1777
+ error: toErrorMessage(error),
1778
+ });
1779
+ });
1780
+ }
1781
+ }
1782
+ async buildReplyDrain(event, sessionKey, sessionId, runtimePrompt, cwd, turn, settings) {
1783
+ try {
1784
+ // Live co-drive: inject the turn and collect the reply text from the turn's
1785
+ // mirrored bus events (the server's onMirror persists them — we only read).
1786
+ const started = await this.conversationRuntime.runLive({
1787
+ message: runtimePrompt,
1788
+ threadId: sessionId,
1789
+ provider: settings.providerExplicit || !settings.agentName ? settings.provider : undefined,
1790
+ model: settings.model,
1791
+ reasoningEffort: settings.reasoningEffort,
1792
+ agentName: settings.agentName,
1793
+ cwd,
1794
+ signal: turn.abort.signal,
1795
+ steer: turn.steer,
1796
+ });
1797
+ const answer = await collectLiveRun(started.generator);
1798
+ return {
1799
+ text: answer.trim() || DEFAULT_EMPTY_RESPONSE,
1800
+ provider: started.provider,
1801
+ model: started.model,
1802
+ };
1803
+ }
1804
+ catch (error) {
1805
+ const errorMessage = toErrorMessage(error);
1806
+ this.logger.log({
1807
+ event: "message.failed",
1808
+ tenant_key: event.tenant_key ?? event.sender.tenant_key,
1809
+ chat_id: event.message.chat_id,
1810
+ thread_id: event.message.thread_id,
1811
+ message_id: event.message.message_id,
1812
+ session_key: sessionKey,
1813
+ session_id: sessionId,
1814
+ provider: settings.provider,
1815
+ error: errorMessage,
1816
+ });
1817
+ return {
1818
+ text: `处理失败:${errorMessage}`,
1819
+ provider: settings.provider,
1820
+ };
1821
+ }
1822
+ }
1823
+ async sendReplyText(event, text) {
1824
+ const chunks = chunkLarkReplyText(text);
1825
+ if (chunks.length === 0) {
1826
+ return false;
1827
+ }
1828
+ for (const [index, chunk] of chunks.entries()) {
1829
+ const uuid = `lark-reply:${event.message.message_id}:${index}`;
1830
+ if (event.message.thread_id) {
1831
+ await this.messageSender.replyText({
1832
+ messageId: event.message.message_id,
1833
+ text: chunk,
1834
+ uuid,
1835
+ replyInThread: true,
1836
+ });
1837
+ }
1838
+ else {
1839
+ await this.messageSender.createText({
1840
+ chatId: event.message.chat_id,
1841
+ text: chunk,
1842
+ uuid,
1843
+ });
1844
+ }
1845
+ }
1846
+ return true;
1847
+ }
1848
+ }
1849
+ /** Per-instance option readers: parse raw `channels.<id>.options` values
1850
+ * (strings from JSON) into the typed shapes the ingestor expects; `undefined`
1851
+ * ⇒ "not set for this instance" so the caller falls back to global config. */
1852
+ function larkStringOption(options, key) {
1853
+ const value = options?.[key];
1854
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
1855
+ }
1856
+ function larkBoolOption(options, key) {
1857
+ const value = options?.[key];
1858
+ if (typeof value === "boolean")
1859
+ return value;
1860
+ if (typeof value === "string" && value.trim())
1861
+ return value.trim().toLowerCase() === "true";
1862
+ return undefined;
1863
+ }
1864
+ function larkListOption(options, key) {
1865
+ const value = options?.[key];
1866
+ if (Array.isArray(value))
1867
+ return value.map(String).map((entry) => entry.trim()).filter(Boolean);
1868
+ if (typeof value === "string" && value.trim()) {
1869
+ return value.split(",").map((entry) => entry.trim()).filter(Boolean);
1870
+ }
1871
+ return undefined;
1872
+ }
1873
+ export class LocalLarkBotRuntime {
1874
+ clientProvider;
1875
+ messageIngestor;
1876
+ logger;
1877
+ connected = false;
1878
+ lastEventAt = null;
1879
+ lastError = null;
1880
+ constructor({ executionDefaults, agentCatalog, conversationRuntime, options, defaultAgent, logger = defaultLogger, clientProvider = new LarkClientProvider(options, larkSdkLogger(logger)), messageIngestor, capabilities, activeSessionStore: injectedActiveSessionStore, conversationDirectory: injectedConversationDirectory, sessionTitleWriter, sessionProvisioner, }) {
1881
+ this.clientProvider = clientProvider;
1882
+ this.logger = logger;
1883
+ if (messageIngestor) {
1884
+ this.messageIngestor = messageIngestor;
1885
+ return;
1886
+ }
1887
+ if (!clientProvider) {
1888
+ return;
1889
+ }
1890
+ if (!injectedConversationDirectory || !injectedActiveSessionStore) {
1891
+ throw new Error("LocalLarkBotRuntime requires explicit plugin-scoped state stores");
1892
+ }
1893
+ const conversationDirectory = injectedConversationDirectory;
1894
+ // The conversation → current-session routing map is plugin-owned state.
1895
+ const activeSessionStore = injectedActiveSessionStore;
1896
+ const messageSender = clientProvider.getMessageSender();
1897
+ const larkCwd = larkStringOption(options, "LARK_CWD");
1898
+ if (!executionDefaults || !agentCatalog) {
1899
+ throw new Error("LocalLarkBotRuntime requires host execution defaults and an agent catalog adapter");
1900
+ }
1901
+ const allowedRoots = [...new Set([
1902
+ ...executionDefaults.allowedRoots.map((root) => path.resolve(root)),
1903
+ ...(larkCwd ? [path.resolve(larkCwd)] : []),
1904
+ ])];
1905
+ const ingestor = new LarkMessageIngestor({
1906
+ conversationRuntime,
1907
+ messageSender,
1908
+ cwd: larkCwd,
1909
+ defaultRuntime: executionDefaults.runtime,
1910
+ defaultAgent: defaultAgent ?? executionDefaults.defaultAgent,
1911
+ agentCatalog,
1912
+ activeSessionStore,
1913
+ conversationDirectory,
1914
+ logger,
1915
+ capabilities,
1916
+ allowedRoots,
1917
+ disableP2p: larkBoolOption(options, "LARK_DISABLE_P2P") ?? false,
1918
+ groupAllowlistEnabled: larkBoolOption(options, "LARK_GROUP_ALLOWLIST_ENABLED") ?? false,
1919
+ groupAllowlist: larkListOption(options, "LARK_GROUP_ALLOWLIST") ?? [],
1920
+ sessionTitleWriter,
1921
+ sessionProvisioner,
1922
+ });
1923
+ this.messageIngestor = ingestor;
1924
+ }
1925
+ getStatus() {
1926
+ return {
1927
+ connected: this.connected,
1928
+ mode: "ws",
1929
+ last_event_at: this.lastEventAt,
1930
+ last_error: this.lastError,
1931
+ };
1932
+ }
1933
+ async start() {
1934
+ if (!this.clientProvider || !this.messageIngestor) {
1935
+ return;
1936
+ }
1937
+ // Resolve our own open_id before connecting so group `@`-mention routing is
1938
+ // in effect for the very first message. Best-effort: on failure the
1939
+ // ingestor keeps its legacy "any mention triggers" behavior.
1940
+ if (this.clientProvider.getBotOpenId) {
1941
+ const botOpenId = await this.clientProvider.getBotOpenId().catch(() => null);
1942
+ this.messageIngestor.setBotOpenId(botOpenId);
1943
+ this.logger.log({
1944
+ event: botOpenId ? "runtime.bot_identity_resolved" : "runtime.bot_identity_unresolved",
1945
+ });
1946
+ }
1947
+ try {
1948
+ await this.clientProvider.start({
1949
+ onMessage: (event) => {
1950
+ this.lastEventAt = new Date().toISOString();
1951
+ void this.messageIngestor?.ingest(event).catch((error) => {
1952
+ this.lastError = toErrorMessage(error);
1953
+ this.logger.log({
1954
+ event: "runtime.ingest_failed",
1955
+ error: this.lastError,
1956
+ tenant_key: event.tenant_key ?? event.sender.tenant_key,
1957
+ chat_id: event.message.chat_id,
1958
+ thread_id: event.message.thread_id,
1959
+ message_id: event.message.message_id,
1960
+ });
1961
+ });
1962
+ },
1963
+ onCardAction: async (cardEvent) => {
1964
+ this.lastEventAt = new Date().toISOString();
1965
+ try {
1966
+ return await this.messageIngestor.ingestCardAction(cardEvent);
1967
+ }
1968
+ catch (error) {
1969
+ this.lastError = toErrorMessage(error);
1970
+ this.logger.log({
1971
+ event: "runtime.card_action_failed",
1972
+ error: this.lastError,
1973
+ message_id: cardEvent.messageId,
1974
+ });
1975
+ return {
1976
+ toast: {
1977
+ type: "error",
1978
+ content: `卡片操作失败:${this.lastError}`,
1979
+ },
1980
+ };
1981
+ }
1982
+ },
1983
+ });
1984
+ this.connected = true;
1985
+ this.lastError = null;
1986
+ this.logger.log({
1987
+ event: "runtime.connected",
1988
+ });
1989
+ }
1990
+ catch (error) {
1991
+ this.connected = false;
1992
+ this.lastError = toErrorMessage(error);
1993
+ this.logger.log({
1994
+ event: "runtime.connection_failed",
1995
+ error: this.lastError,
1996
+ });
1997
+ throw error;
1998
+ }
1999
+ }
2000
+ async stop() {
2001
+ if (!this.clientProvider) {
2002
+ return;
2003
+ }
2004
+ await this.clientProvider.stop();
2005
+ this.connected = false;
2006
+ this.logger.log({
2007
+ event: "runtime.disconnected",
2008
+ });
2009
+ }
2010
+ }