openclaw-lark-multi-agent 1.4.13 → 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,5 @@
1
1
  /** Coarse phase of the compaction flow, drives the card's primary line. */
2
- export type CompactPhase = "native" | "transcript-trim";
2
+ export type CompactPhase = "native" | "transcript-trim" | "trim";
3
3
  /** Terminal outcome used by the renderer to pick color/emoji. */
4
4
  export type CompactState = "running" | "done" | "failed" | "noop";
5
5
  /** Structured snapshot the renderer turns into a Feishu interactive card. */
@@ -50,6 +50,8 @@ export declare class CompactProgressController {
50
50
  get id(): string | undefined;
51
51
  /** Begin the flow; schedules lazy card creation after delayMs. */
52
52
  start(): void;
53
+ /** Switch straight into explicit transcript-trim mode (user-requested trim). */
54
+ toTrim(): void;
53
55
  /** Native compaction gave up; switch to Gateway-owned transcript-tail trim. */
54
56
  toTranscriptTrim(): Promise<void>;
55
57
  /** Finished successfully; patch the card to the done state with a detail line. */
@@ -45,6 +45,12 @@ export class CompactProgressController {
45
45
  }, this.opts.delayMs ?? DEFAULT_DELAY_MS);
46
46
  this.createTimer.unref?.();
47
47
  }
48
+ /** Switch straight into explicit transcript-trim mode (user-requested trim). */
49
+ toTrim() {
50
+ if (this.finalized || this.phase === "trim" || this.phase === "transcript-trim")
51
+ return;
52
+ this.phase = "trim";
53
+ }
48
54
  /** Native compaction gave up; switch to Gateway-owned transcript-tail trim. */
49
55
  async toTranscriptTrim() {
50
56
  if (this.finalized || this.phase === "transcript-trim")
package/dist/config.d.ts CHANGED
@@ -5,6 +5,8 @@ export interface BotConfig {
5
5
  appSecret: string;
6
6
  model: string;
7
7
  locale?: Locale;
8
+ /** Optional per-bot thinking level (informational fallback for footers). */
9
+ thinking?: string;
8
10
  }
9
11
  export interface OpenClawConfig {
10
12
  baseUrl: string;
@@ -219,6 +219,10 @@ export declare class FeishuBot {
219
219
  private isBotAvailableInChat;
220
220
  private getDiscussionParticipants;
221
221
  private getChairmanParticipant;
222
+ /** Resolve the session's current thinking label for the live-status footer.
223
+ * Prefers a live session override (set via //think / sessions.patch), falls
224
+ * back to the session's default, then the bot's config-level thinking. */
225
+ private fetchThinkingLabel;
222
226
  private asDiscussionParticipant;
223
227
  private runDiscussionTurn;
224
228
  private buildLiveStatusCard;
@@ -286,6 +290,15 @@ export declare class FeishuBot {
286
290
  */
287
291
  private sendErrorNotice;
288
292
  private sendFinalMessage;
293
+ /** Feishu code 230025: message content over the per-message limit. */
294
+ private isContentTooLongError;
295
+ /** Split text into Feishu-safe chunks (default well under the 4000-char text limit).
296
+ * Prefers paragraph, then line boundaries; hard-cuts only when a single line
297
+ * is longer than the limit. Joining all chunks reproduces the original text. */
298
+ private splitIntoChunks;
299
+ /** Deliver oversized content as multiple plain-text messages (used on 230025).
300
+ * Keeps the model footer on the final chunk. Returns the first message id. */
301
+ private sendChunkedText;
289
302
  private sendMessage;
290
303
  /**
291
304
  * Enqueue a message send to guarantee ordering per chat.
@@ -316,14 +329,15 @@ export declare class FeishuBot {
316
329
  * Handle /compact command: compress session context.
317
330
  */
318
331
  /**
319
- * Compact a session with a Gateway-owned fallback chain:
320
- * 1. Semantic compaction through `sessions.compact`.
321
- * 2. If summarization cannot run, ask the same RPC to retain only a bounded
322
- * transcript tail via `maxLines`. This works with legacy JSONL and the
323
- * SQLite session store introduced in OpenClaw 2026.8.x, and preserves
324
- * OpenClaw's write locks, transcript invariants, and migration ownership.
332
+ * Compact a session via Gateway-owned RPCs. LMA deliberately never opens or
333
+ * rewrites OpenClaw transcript storage.
325
334
  *
326
- * LMA deliberately never opens or rewrites OpenClaw transcript storage.
335
+ * Modes (kept strictly separate so the flows are never mixed):
336
+ * - "trim" (default): `sessions.compact` with `maxLines` — permanently keeps
337
+ * only the last N transcript events. Model-free, fast, never times out on
338
+ * oversized sessions.
339
+ * - "semantic": LLM summarization via the same RPC; on failure falls back to
340
+ * a trim so the session is still reclaimed.
327
341
  */
328
342
  private compactWithFallback;
329
343
  private handleCompactCommand;
@@ -344,9 +358,6 @@ export declare class FeishuBot {
344
358
  * Called once per chat on first message.
345
359
  */
346
360
  private fetchAndCacheChatInfo;
347
- /**
348
- * Send a model-drift notification to the affected chat.
349
- */
350
361
  private hydrateInlineImageKeys;
351
362
  private hydrateFeishuDocsInMessage;
352
363
  private extractFeishuDocRefs;
@@ -365,5 +376,4 @@ export declare class FeishuBot {
365
376
  * Extract text content from a rich text (post) message.
366
377
  */
367
378
  private extractPostText;
368
- private notifyModelDrift;
369
379
  }
@@ -37,6 +37,40 @@ const AUTO_RETRY_DONE_PHRASE_EN = process.env.OPENCLAW_LARK_MULTI_AGENT_AUTO_RET
37
37
  // it is a NEW user message that arrived while it was still working, and should
38
38
  // be handled together with the current task. The live-status card and reactions
39
39
  // still key off the ORIGINAL text; only what OpenClaw receives is wrapped.
40
+ /** Damerau-Levenshtein distance (insert/delete/substitute/transpose), bounded. */
41
+ function damerauLevenshtein(a, b) {
42
+ const la = a.length, lb = b.length;
43
+ if (Math.abs(la - lb) > 1)
44
+ return 2; // fast reject for threshold-1 use
45
+ const d = Array.from({ length: la + 1 }, () => new Array(lb + 1).fill(0));
46
+ for (let i = 0; i <= la; i++)
47
+ d[i][0] = i;
48
+ for (let j = 0; j <= lb; j++)
49
+ d[0][j] = j;
50
+ for (let i = 1; i <= la; i++) {
51
+ for (let j = 1; j <= lb; j++) {
52
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
53
+ d[i][j] = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + cost);
54
+ if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) {
55
+ d[i][j] = Math.min(d[i][j], d[i - 2][j - 2] + 1);
56
+ }
57
+ }
58
+ }
59
+ return d[la][lb];
60
+ }
61
+ /** Find a bridge command within one edit of `name` (typo hint); undefined if none. */
62
+ function findNearMissCommand(name, commands) {
63
+ if (!name.startsWith("/") || name.length < 3)
64
+ return undefined;
65
+ let best;
66
+ for (const cmd of commands) {
67
+ if (damerauLevenshtein(name, cmd) <= 1) {
68
+ if (best === undefined || cmd.length < best.length)
69
+ best = cmd;
70
+ }
71
+ }
72
+ return best;
73
+ }
40
74
  function steerInjectionPrefix(en) {
41
75
  const zh = process.env.OPENCLAW_LARK_MULTI_AGENT_STEER_PREFIX
42
76
  || "【用户中途插入的新消息,请与当前任务一起处理】";
@@ -44,12 +78,12 @@ function steerInjectionPrefix(en) {
44
78
  || "[New message inserted by the user mid-task; please handle it together with the current task]";
45
79
  return en ? enText : zh;
46
80
  }
47
- // Emergency transcript trim used only when semantic compaction cannot run.
48
- // The Gateway owns the transcript store (JSONL before 2026.8, SQLite after it),
49
- // so LMA must use sessions.compact instead of mutating storage directly.
81
+ // Default transcript-tail trim depth for /compact. Tool-heavy group sessions can
82
+ // easily carry 300+ events of pure tool noise, so keep a generous tail: the
83
+ // Gateway truncates to the last N transcript events. Env override stays.
50
84
  function compactFallbackMaxLines() {
51
- const value = Number(process.env.OPENCLAW_LARK_MULTI_AGENT_COMPACT_FALLBACK_MAX_LINES || 200);
52
- return Number.isFinite(value) ? Math.max(20, Math.floor(value)) : 200;
85
+ const value = Number(process.env.OPENCLAW_LARK_MULTI_AGENT_COMPACT_FALLBACK_MAX_LINES || 800);
86
+ return Number.isFinite(value) ? Math.max(20, Math.floor(value)) : 800;
53
87
  }
54
88
  // After a reply is delivered, if context usage reaches this percent, send the
55
89
  // user a one-time alert to compact. 0 disables the alert.
@@ -212,7 +246,6 @@ export class FeishuBot {
212
246
  const corrected = await this.openclawClient.ensureModel(sessionKey, this.config.model);
213
247
  if (corrected) {
214
248
  console.log(`[${this.config.name}] Model auto-corrected to ${this.config.model}`);
215
- await this.notifyModelDrift(chatId, sessionKey);
216
249
  }
217
250
  return sessionKey;
218
251
  }
@@ -225,7 +258,6 @@ export class FeishuBot {
225
258
  const corrected = await this.openclawClient.ensureModel(sessionKey, this.config.model);
226
259
  if (corrected) {
227
260
  console.log(`[${this.config.name}] Model auto-corrected to ${this.config.model}`);
228
- await this.notifyModelDrift(chatId, sessionKey);
229
261
  }
230
262
  }
231
263
  else {
@@ -572,6 +604,28 @@ export class FeishuBot {
572
604
  const commandName = preliminaryCommandName;
573
605
  const bridgeCommands = preliminaryBridgeCommands;
574
606
  const isCommand = isBridgeCommand && bridgeCommands.has(commandName);
607
+ // Typo hint: a near-miss single-slash command (e.g. "/chiarman") must not
608
+ // silently fall through into the chairman/model chat path — that is how
609
+ // "I set the chairman but someone else keeps answering" confusion starts.
610
+ if (!isCommand && isBridgeCommand) {
611
+ const nearMiss = findNearMissCommand(commandName, bridgeCommands);
612
+ if (nearMiss) {
613
+ const groupBotCount = Array.from(FeishuBot.allBots.values()).filter((bot) => bot.store === this.store).length;
614
+ const shouldHint = chatType === "p2p"
615
+ || (routing.hasTargetedMention ? routing.isCurrentBotMentioned : (groupBotCount === 1 || this.isDiscussionCoordinator()));
616
+ if (shouldHint) {
617
+ const en = this.isEn(chatId);
618
+ await this.replyMessage(messageId, en
619
+ ? `❓ Unknown command \`${commandName}\`. Did you mean \`${nearMiss}\`?\nRe-send the correct command to execute it. Commands starting with // are passed to OpenClaw.`
620
+ : `❓ 未知命令 \`${commandName}\`,你是想输入 \`${nearMiss}\` 吗?\n重新发送正确命令即可执行;双斜杠 // 开头的命令会转交给 OpenClaw。`);
621
+ if (insertedId > 0) {
622
+ this.store.markSynced(this.config.name, chatId, insertedId);
623
+ this.store.clearPendingTrigger(this.config.name, chatId, insertedId);
624
+ }
625
+ return;
626
+ }
627
+ }
628
+ }
575
629
  if (isCommand) {
576
630
  // In group chats, most bridge commands must be explicitly routed to this
577
631
  // bot or @all. /discuss is a group-level command, so an unmentioned
@@ -683,7 +737,7 @@ export class FeishuBot {
683
737
  `━━━━━━━━━━━━━━━━━━`,
684
738
  `桥接层命令(单斜杠,由 openclaw-lark-multi-agent 本地处理)`,
685
739
  `📊 /status — 查看当前模型、Token 用量、Session 状态`,
686
- `🧹 /compact — 压缩当前 bot 的 OpenClaw session`,
740
+ `🧹 /compact [N|maxLines=N|semantic] — 裁剪转录尾部(默认,保留最近事件);N 指定保留条数;semantic 走 LLM 总结`,
687
741
  `🔄 /reset — 重置当前 bot 的 OpenClaw session`,
688
742
  `⏹️ /stop — 强制停止当前 bot 在本聊天的卡死 run,解锁队列`,
689
743
  `🔊 /verbose — 开关当前聊天里的 Tool Call 显示`,
@@ -729,7 +783,8 @@ export class FeishuBot {
729
783
  }
730
784
  if (commandName === "/compact") {
731
785
  await this.ensureSession(chatId);
732
- await this.handleCompactCommand(chatId, messageId);
786
+ const compactArgs = cleanText.trim().split(/\s+/).slice(1).join(" ").trim();
787
+ await this.handleCompactCommand(chatId, messageId, compactArgs);
733
788
  markCommandSynced();
734
789
  return;
735
790
  }
@@ -1360,6 +1415,7 @@ export class FeishuBot {
1360
1415
  }, {
1361
1416
  botName: this.config.name,
1362
1417
  model: this.config.model,
1418
+ thinkingLevel: await this.fetchThinkingLabel(sessionKey),
1363
1419
  locale: this.isEn(chatId) ? "en" : "zh",
1364
1420
  })
1365
1421
  : undefined;
@@ -1888,7 +1944,12 @@ export class FeishuBot {
1888
1944
  const core = en
1889
1945
  ? `${emoji} ${meta.toolCalls} tool call${meta.toolCalls === 1 ? "" : "s"} · ⏱ ${meta.elapsed}`
1890
1946
  : `${emoji} 累计${meta.toolCalls} 次工具调用 · ⏱ 耗时${meta.elapsed}`;
1891
- return meta.model?.trim() ? `${core} · 🧠 ${this.escapeCardText(meta.model.trim())}` : core;
1947
+ if (!meta.model?.trim())
1948
+ return core;
1949
+ const modelLabel = meta.thinkingLevel?.trim()
1950
+ ? `${meta.model.trim()} · ${meta.thinkingLevel.trim()}`
1951
+ : meta.model.trim();
1952
+ return `${core} · 🧠 ${this.escapeCardText(modelLabel)}`;
1892
1953
  }
1893
1954
  /** Best-effort terminal cleanup for status-only rows and legacy v1.4.3
1894
1955
  * outbox entries that once targeted the live-status card. */
@@ -1898,6 +1959,7 @@ export class FeishuBot {
1898
1959
  lines: [],
1899
1960
  elapsed: meta.elapsed,
1900
1961
  model: meta.model,
1962
+ thinkingLevel: meta.thinkingLevel,
1901
1963
  toolCalls: meta.toolCalls,
1902
1964
  noReply: Boolean(meta.noReply),
1903
1965
  state: "done",
@@ -2783,6 +2845,24 @@ export class FeishuBot {
2783
2845
  const bot = Array.from(FeishuBot.allBots.values()).find((candidate) => candidate.store === this.store && candidate.config.name === chairman && this.isBotAvailableInChat(candidate, chatId));
2784
2846
  return bot ? this.asDiscussionParticipant(bot, chatId) : undefined;
2785
2847
  }
2848
+ /** Resolve the session's current thinking label for the live-status footer.
2849
+ * Prefers a live session override (set via //think / sessions.patch), falls
2850
+ * back to the session's default, then the bot's config-level thinking. */
2851
+ async fetchThinkingLabel(sessionKey) {
2852
+ try {
2853
+ const info = await this.openclawClient.getSessionInfo(sessionKey);
2854
+ const s = info?.session;
2855
+ const label = (typeof s?.thinkingLevel === "string" && s.thinkingLevel.trim())
2856
+ || (typeof s?.thinkingDefault === "string" && s.thinkingDefault.trim())
2857
+ || (typeof this.config.thinking === "string" && this.config.thinking.trim())
2858
+ || "";
2859
+ return label || undefined;
2860
+ }
2861
+ catch {
2862
+ const cfg = typeof this.config.thinking === "string" ? this.config.thinking.trim() : "";
2863
+ return cfg || undefined;
2864
+ }
2865
+ }
2786
2866
  asDiscussionParticipant(bot, chatId) {
2787
2867
  return {
2788
2868
  name: bot.config.name,
@@ -2801,6 +2881,7 @@ export class FeishuBot {
2801
2881
  }, {
2802
2882
  botName: this.config.name,
2803
2883
  model: this.config.model,
2884
+ thinkingLevel: await this.fetchThinkingLabel(sessionKey),
2804
2885
  locale: this.isEn(chatId) ? "en" : "zh",
2805
2886
  })
2806
2887
  : undefined;
@@ -2900,6 +2981,7 @@ export class FeishuBot {
2900
2981
  toolCalls: view.toolCalls,
2901
2982
  elapsed: view.elapsed,
2902
2983
  model: view.model,
2984
+ thinkingLevel: view.thinkingLevel,
2903
2985
  locale: en ? "en" : "zh",
2904
2986
  }, statusEmoji);
2905
2987
  return {
@@ -2935,8 +3017,12 @@ export class FeishuBot {
2935
3017
  // Keep the active model visible while the run is in progress or failed.
2936
3018
  // The final reply also keeps model attribution so it remains self-contained.
2937
3019
  const footerBits = [en ? `⏱ ${view.elapsed}` : `⏱ 已用 ${view.elapsed}`];
2938
- if (view.model?.trim())
2939
- footerBits.push(`🧠 ${this.escapeCardText(view.model.trim())}`);
3020
+ if (view.model?.trim()) {
3021
+ const modelBit = view.thinkingLevel?.trim()
3022
+ ? `🧠 ${this.escapeCardText(view.model.trim())} · ${this.escapeCardText(view.thinkingLevel.trim())}`
3023
+ : `🧠 ${this.escapeCardText(view.model.trim())}`;
3024
+ footerBits.push(modelBit);
3025
+ }
2940
3026
  elements.push({ tag: "markdown", content: `<font color='grey'>${footerBits.join(" · ")}</font>` });
2941
3027
  const template = view.state === "failed" ? "orange" : "blue";
2942
3028
  return {
@@ -3023,7 +3109,9 @@ export class FeishuBot {
3023
3109
  // Running: phase line + ticking elapsed footer.
3024
3110
  const phaseLine = view.phase === "transcript-trim"
3025
3111
  ? (en ? "🧹 Semantic compaction unavailable — trimming transcript safely…" : "🧹 语义压缩不可用,正在安全裁剪转录…")
3026
- : (en ? "🧹 Compacting session…" : "🧹 正在压缩 session…");
3112
+ : view.phase === "trim"
3113
+ ? (en ? "🧹 Trimming transcript tail (keeping the most recent events)…" : "🧹 正在裁剪转录尾部(保留最近的事件)…")
3114
+ : (en ? "🧹 Compacting session…" : "🧹 正在压缩 session…");
3027
3115
  const footer = en ? `⏱ ${view.elapsed}` : `⏱ 已用 ${view.elapsed}`;
3028
3116
  return {
3029
3117
  schema: "2.0",
@@ -3117,6 +3205,16 @@ export class FeishuBot {
3117
3205
  const model = this.replyModelFooters.get(messageId);
3118
3206
  const status = this.replyStatusFooters.get(messageId);
3119
3207
  const card = this.buildMarkdownCard(text, model, status);
3208
+ // Best-effort chat resolution for chunked/error paths: the reply target is
3209
+ // normally a stored inbound message, so the store knows its chat.
3210
+ const resolveChatId = () => {
3211
+ try {
3212
+ return this.store.getMessageByMessageId(messageId)?.chatId || undefined;
3213
+ }
3214
+ catch {
3215
+ return undefined;
3216
+ }
3217
+ };
3120
3218
  try {
3121
3219
  const res = await this.client.im.message.reply({
3122
3220
  path: { message_id: messageId },
@@ -3127,17 +3225,35 @@ export class FeishuBot {
3127
3225
  });
3128
3226
  return res?.data?.message_id || res?.message_id;
3129
3227
  }
3130
- catch {
3228
+ catch (err) {
3229
+ const chatId = resolveChatId();
3230
+ if (this.isOutOfChatError(err) && chatId)
3231
+ this.markCurrentBotUnavailable(chatId, err);
3232
+ // Content over Feishu's per-message limit: chunk and deliver instead of
3233
+ // retrying the same oversized payload (retries would fail forever).
3234
+ if (this.isContentTooLongError(err) && chatId) {
3235
+ return await this.sendChunkedText(chatId, text, model, status, messageId);
3236
+ }
3131
3237
  // Fallback to plain text if card fails; retain model attribution.
3132
3238
  const fallbackText = model?.trim() ? `${text}\n\n🧠 ${model.trim()}${status ? ` · ${status}` : ""}` : text;
3133
- const res = await this.client.im.message.reply({
3134
- path: { message_id: messageId },
3135
- data: {
3136
- content: JSON.stringify({ text: fallbackText }),
3137
- msg_type: "text",
3138
- },
3139
- });
3140
- return res?.data?.message_id || res?.message_id;
3239
+ try {
3240
+ const res = await this.client.im.message.reply({
3241
+ path: { message_id: messageId },
3242
+ data: {
3243
+ content: JSON.stringify({ text: fallbackText }),
3244
+ msg_type: "text",
3245
+ },
3246
+ });
3247
+ return res?.data?.message_id || res?.message_id;
3248
+ }
3249
+ catch (fallbackErr) {
3250
+ if (this.isOutOfChatError(fallbackErr) && chatId)
3251
+ this.markCurrentBotUnavailable(chatId, fallbackErr);
3252
+ if (this.isContentTooLongError(fallbackErr) && chatId) {
3253
+ return await this.sendChunkedText(chatId, text, model, status, messageId);
3254
+ }
3255
+ throw fallbackErr;
3256
+ }
3141
3257
  }
3142
3258
  }
3143
3259
  extractBridgeAttachments(reply) {
@@ -3485,6 +3601,59 @@ export class FeishuBot {
3485
3601
  this.sendStatusFooters.delete(chatId);
3486
3602
  }
3487
3603
  }
3604
+ /** Feishu code 230025: message content over the per-message limit. */
3605
+ isContentTooLongError(err) {
3606
+ const e = err;
3607
+ const code = e?.response?.data?.code ?? e?.data?.code ?? e?.code;
3608
+ const msg = String(e?.response?.data?.msg ?? e?.data?.msg ?? e?.message ?? e?.msg ?? "");
3609
+ return code === 230025 || msg.includes("230025") || /length of the message content/i.test(msg);
3610
+ }
3611
+ /** Split text into Feishu-safe chunks (default well under the 4000-char text limit).
3612
+ * Prefers paragraph, then line boundaries; hard-cuts only when a single line
3613
+ * is longer than the limit. Joining all chunks reproduces the original text. */
3614
+ splitIntoChunks(text, limit = 3200) {
3615
+ if (text.length <= limit)
3616
+ return [text];
3617
+ const chunks = [];
3618
+ let rest = text;
3619
+ while (rest.length > limit) {
3620
+ let cut = rest.lastIndexOf("\n\n", limit);
3621
+ if (cut < limit * 0.5)
3622
+ cut = rest.lastIndexOf("\n", limit);
3623
+ if (cut < limit * 0.5)
3624
+ cut = rest.lastIndexOf(" ", limit);
3625
+ if (cut < limit * 0.5)
3626
+ cut = limit;
3627
+ chunks.push(rest.slice(0, cut));
3628
+ rest = rest.slice(cut).replace(/^\n+/, "");
3629
+ }
3630
+ if (rest.length > 0)
3631
+ chunks.push(rest);
3632
+ return chunks;
3633
+ }
3634
+ /** Deliver oversized content as multiple plain-text messages (used on 230025).
3635
+ * Keeps the model footer on the final chunk. Returns the first message id. */
3636
+ async sendChunkedText(chatId, text, model, status, replyTo) {
3637
+ const suffix = model?.trim() ? `\n\n🧠 ${model.trim()}${status ? ` · ${status}` : ""}` : "";
3638
+ const chunks = this.splitIntoChunks(text);
3639
+ if (suffix && chunks.length > 0)
3640
+ chunks[chunks.length - 1] += suffix;
3641
+ let firstId;
3642
+ for (let i = 0; i < chunks.length; i++) {
3643
+ const payload = {
3644
+ content: JSON.stringify({ text: chunks[i] }),
3645
+ msg_type: "text",
3646
+ };
3647
+ const res = replyTo
3648
+ ? await this.client.im.message.reply({ path: { message_id: replyTo }, data: payload })
3649
+ : await this.client.im.message.create({ params: { receive_id_type: "chat_id" }, data: { receive_id: chatId, ...payload } });
3650
+ const id = res?.data?.message_id || res?.message_id;
3651
+ if (i === 0)
3652
+ firstId = id;
3653
+ }
3654
+ this.store.clearBotUnavailableInChat(this.config.name, chatId);
3655
+ return firstId;
3656
+ }
3488
3657
  async sendMessage(chatId, text) {
3489
3658
  const model = this.sendModelFooters.get(chatId);
3490
3659
  const status = this.sendStatusFooters.get(chatId);
@@ -3505,6 +3674,17 @@ export class FeishuBot {
3505
3674
  console.warn(`[${this.config.name}] sendMessage interactive failed:`, JSON.stringify(err?.response?.data || err?.data || { message: err.message }));
3506
3675
  if (this.isOutOfChatError(err))
3507
3676
  this.markCurrentBotUnavailable(chatId, err);
3677
+ // Content over Feishu's per-message limit: chunk and deliver instead of
3678
+ // retrying the same oversized payload (retries would fail forever).
3679
+ if (this.isContentTooLongError(err)) {
3680
+ try {
3681
+ return await this.sendChunkedText(chatId, text, model, status);
3682
+ }
3683
+ catch (chunkErr) {
3684
+ console.warn(`[${this.config.name}] sendMessage chunked delivery failed:`, chunkErr.message);
3685
+ throw chunkErr;
3686
+ }
3687
+ }
3508
3688
  // Fallback to plain text; retain model attribution.
3509
3689
  try {
3510
3690
  const fallbackText = model?.trim() ? `${text}\n\n🧠 ${model.trim()}${status ? ` · ${status}` : ""}` : text;
@@ -3523,6 +3703,9 @@ export class FeishuBot {
3523
3703
  console.warn(`[${this.config.name}] sendMessage text failed:`, JSON.stringify(fallbackErr?.response?.data || fallbackErr?.data || { message: fallbackErr.message }));
3524
3704
  if (this.isOutOfChatError(fallbackErr))
3525
3705
  this.markCurrentBotUnavailable(chatId, fallbackErr);
3706
+ if (this.isContentTooLongError(fallbackErr)) {
3707
+ return await this.sendChunkedText(chatId, text, model, status);
3708
+ }
3526
3709
  throw fallbackErr;
3527
3710
  }
3528
3711
  }
@@ -3800,16 +3983,37 @@ export class FeishuBot {
3800
3983
  * Handle /compact command: compress session context.
3801
3984
  */
3802
3985
  /**
3803
- * Compact a session with a Gateway-owned fallback chain:
3804
- * 1. Semantic compaction through `sessions.compact`.
3805
- * 2. If summarization cannot run, ask the same RPC to retain only a bounded
3806
- * transcript tail via `maxLines`. This works with legacy JSONL and the
3807
- * SQLite session store introduced in OpenClaw 2026.8.x, and preserves
3808
- * OpenClaw's write locks, transcript invariants, and migration ownership.
3986
+ * Compact a session via Gateway-owned RPCs. LMA deliberately never opens or
3987
+ * rewrites OpenClaw transcript storage.
3809
3988
  *
3810
- * LMA deliberately never opens or rewrites OpenClaw transcript storage.
3989
+ * Modes (kept strictly separate so the flows are never mixed):
3990
+ * - "trim" (default): `sessions.compact` with `maxLines` — permanently keeps
3991
+ * only the last N transcript events. Model-free, fast, never times out on
3992
+ * oversized sessions.
3993
+ * - "semantic": LLM summarization via the same RPC; on failure falls back to
3994
+ * a trim so the session is still reclaimed.
3811
3995
  */
3812
- async compactWithFallback(sessionKey, onPhase) {
3996
+ async compactWithFallback(sessionKey, onPhase, opts = {}) {
3997
+ const mode = opts.mode === "semantic" ? "semantic" : "trim";
3998
+ const trimMaxLines = Number.isFinite(opts.maxLines) && opts.maxLines >= 20
3999
+ ? Math.floor(opts.maxLines)
4000
+ : compactFallbackMaxLines();
4001
+ if (mode === "trim") {
4002
+ onPhase?.("trim");
4003
+ try {
4004
+ const trimmed = await this.openclawClient.compactSession(sessionKey, { maxLines: trimMaxLines });
4005
+ if (trimmed?.compacted === true) {
4006
+ const kept = typeof trimmed?.kept === "number" ? trimmed.kept : trimMaxLines;
4007
+ return { compacted: true, method: "transcript-trim", detail: `kept ${kept} transcript events` };
4008
+ }
4009
+ const trimReason = typeof trimmed?.reason === "string" ? trimmed.reason : undefined;
4010
+ return { compacted: false, method: "none", reason: trimReason || "transcript trim made no change" };
4011
+ }
4012
+ catch (err) {
4013
+ return { compacted: false, method: "none", reason: err.message };
4014
+ }
4015
+ }
4016
+ // semantic mode: LLM summarize first, trim as fallback.
3813
4017
  let nativeReason;
3814
4018
  try {
3815
4019
  const res = await this.openclawClient.compactSession(sessionKey);
@@ -3821,11 +4025,10 @@ export class FeishuBot {
3821
4025
  nativeReason = err.message;
3822
4026
  }
3823
4027
  onPhase?.("transcript-trim");
3824
- const maxLines = compactFallbackMaxLines();
3825
4028
  try {
3826
- const trimmed = await this.openclawClient.compactSession(sessionKey, { maxLines });
4029
+ const trimmed = await this.openclawClient.compactSession(sessionKey, { maxLines: trimMaxLines });
3827
4030
  if (trimmed?.compacted === true) {
3828
- const kept = typeof trimmed?.kept === "number" ? trimmed.kept : maxLines;
4031
+ const kept = typeof trimmed?.kept === "number" ? trimmed.kept : trimMaxLines;
3829
4032
  return {
3830
4033
  compacted: true,
3831
4034
  method: "transcript-trim",
@@ -3850,25 +4053,42 @@ export class FeishuBot {
3850
4053
  };
3851
4054
  }
3852
4055
  }
3853
- async handleCompactCommand(chatId, messageId) {
4056
+ async handleCompactCommand(chatId, messageId, args = "") {
3854
4057
  const sessionKey = this.getSessionKey(chatId);
3855
4058
  const en = this.isEn(chatId);
3856
- // Large sessions can take tens of seconds to compact (native compaction may
3857
- // even time out before the transcript-trim fallback kicks in). Show a ticking
3858
- // "compacting…" card so the user can SEE it is working instead of staring at
3859
- // nothing and assuming nothing is happening. The card is created lazily, so
3860
- // a fast compaction that finishes quickly never flashes a card at all.
4059
+ // Argument parsing — modes are strictly separated:
4060
+ // /compact → transcript tail trim (default, model-free)
4061
+ // /compact semantic → LLM summarization (trim fallback on failure)
4062
+ // /compact 500 → tail trim keeping the last 500 events
4063
+ // /compact maxLines=500 → same as above
4064
+ const argTokens = args.split(/\s+/).filter(Boolean);
4065
+ let mode = "trim";
4066
+ let maxLines;
4067
+ for (const tok of argTokens) {
4068
+ const semanticMatch = /^(semantic|summarize|llm)$/i.exec(tok);
4069
+ if (semanticMatch) {
4070
+ mode = "semantic";
4071
+ continue;
4072
+ }
4073
+ const linesMatch = /^(?:maxlines|max_lines)[=:](\d{2,})$/i.exec(tok) || /^(\d{2,})$/.exec(tok);
4074
+ if (linesMatch) {
4075
+ maxLines = Number(linesMatch[1] || linesMatch[2]);
4076
+ continue;
4077
+ }
4078
+ }
3861
4079
  const progress = new CompactProgressController({
3862
4080
  create: (view) => this.sendOrdered(chatId, () => this.replyCompactCard(messageId, view, chatId)),
3863
4081
  edit: (id, view) => this.sendOrdered(chatId, () => this.patchCompactCard(id, view, chatId)),
3864
4082
  warn: (msg, err) => console.warn(`[${this.config.name}] ${msg}`, err instanceof Error ? err.message : err),
3865
4083
  }, { locale: en ? "en" : "zh" });
4084
+ if (mode === "trim")
4085
+ progress.toTrim();
3866
4086
  progress.start();
3867
4087
  try {
3868
4088
  const r = await this.compactWithFallback(sessionKey, (phase) => {
3869
4089
  if (phase === "transcript-trim")
3870
4090
  void progress.toTranscriptTrim().catch(() => { });
3871
- });
4091
+ }, { mode, maxLines });
3872
4092
  if (r.compacted) {
3873
4093
  // If a card was shown, patch it in place; otherwise (fast finish, no card)
3874
4094
  // fall back to a normal reply so the user still gets confirmation.
@@ -4031,9 +4251,6 @@ export class FeishuBot {
4031
4251
  console.warn(`[${this.config.name}] Failed to fetch chat info:`, err.message);
4032
4252
  }
4033
4253
  }
4034
- /**
4035
- * Send a model-drift notification to the affected chat.
4036
- */
4037
4254
  async hydrateInlineImageKeys(text, messageId) {
4038
4255
  const imageKeyPattern = /\[Image: (img_[^\]\n]+)\]/g;
4039
4256
  const replacements = [];
@@ -4261,24 +4478,4 @@ export class FeishuBot {
4261
4478
  }
4262
4479
  return this.cleanMentions(parts.join(" "));
4263
4480
  }
4264
- async notifyModelDrift(chatId, _sessionKey) {
4265
- try {
4266
- const chatInfo = this.store.getChatInfo(chatId);
4267
- const chatLabel = chatInfo?.chatName || chatId.slice(-8);
4268
- await this.client.im.message.create({
4269
- params: { receive_id_type: "chat_id" },
4270
- data: {
4271
- receive_id: chatId,
4272
- content: JSON.stringify({
4273
- text: `⚠️ 模型漂移已自动纠正\n期望: ${this.config.model}\n已恢复`,
4274
- }),
4275
- msg_type: "text",
4276
- },
4277
- });
4278
- console.log(`[${this.config.name}] Drift notification sent to ${chatLabel}`);
4279
- }
4280
- catch (err) {
4281
- console.warn(`[${this.config.name}] Failed to notify drift:`, err.message);
4282
- }
4283
- }
4284
4481
  }
@@ -16,6 +16,8 @@ export type LiveStatusView = {
16
16
  elapsed: string;
17
17
  /** Footer: model name, e.g. "phgeek-gw/claude-opus-4.8". */
18
18
  model?: string;
19
+ /** Footer: current thinking level label, e.g. "high" / "adaptive". */
20
+ thinkingLevel?: string;
19
21
  /** Total tool calls in the run (for the compact finished summary). */
20
22
  toolCalls: number;
21
23
  /** True when finished via noReply(): the model produced no user-visible reply. */
@@ -29,6 +31,7 @@ export type LiveStatusFinalMeta = {
29
31
  toolCalls: number;
30
32
  elapsed: string;
31
33
  model?: string;
34
+ thinkingLevel?: string;
32
35
  locale: "zh" | "en";
33
36
  noReply?: boolean;
34
37
  };
@@ -46,6 +49,8 @@ export type LiveStatusOptions = {
46
49
  maxChars?: number;
47
50
  /** Model name shown in the footer. */
48
51
  model?: string;
52
+ /** Thinking level label shown after the model name in the footer. */
53
+ thinkingLevel?: string;
49
54
  /** Number of recent activity lines kept in the content area. */
50
55
  historySize?: number;
51
56
  /** Footer/auto-refresh cadence in ms. */
@@ -172,6 +172,7 @@ export class LiveStatusController {
172
172
  toolCalls: this.toolCallCount,
173
173
  elapsed: this.formatElapsed(),
174
174
  model: this.opts.model,
175
+ thinkingLevel: this.opts.thinkingLevel,
175
176
  locale: this.opts.locale === "en" ? "en" : "zh",
176
177
  noReply,
177
178
  };
@@ -403,6 +404,7 @@ export class LiveStatusController {
403
404
  lines,
404
405
  elapsed: this.formatElapsed(),
405
406
  model: this.opts.model,
407
+ thinkingLevel: this.opts.thinkingLevel,
406
408
  toolCalls: this.toolCallCount,
407
409
  noReply: this.noReplyResult,
408
410
  state: this.state,
@@ -143,6 +143,14 @@ export declare class OpenClawClient {
143
143
  model?: string;
144
144
  label?: string;
145
145
  }): Promise<any>;
146
+ /** True when an RPC error is the Gateway's "session is archived" refusal. */
147
+ isArchivedSessionError(err: unknown): boolean;
148
+ /**
149
+ * Restore an archived session (e.g. auto-archived by the Gateway's
150
+ * active-session-cap) so new work can start. sessions.patch requires
151
+ * expectedSessionId, so describe first, then patch archived=false.
152
+ */
153
+ unarchiveSession(key: string): Promise<boolean>;
146
154
  getSessionStatus(key: string): Promise<any>;
147
155
  injectAssistantMessage(params: {
148
156
  sessionKey: string;
@@ -1545,6 +1545,34 @@ export class OpenClawClient {
1545
1545
  async patchSession(params) {
1546
1546
  return this.rpc("sessions.patch", { ...params, key: this.canonicalSessionKey(params.key) }, 10000);
1547
1547
  }
1548
+ /** True when an RPC error is the Gateway's "session is archived" refusal. */
1549
+ isArchivedSessionError(err) {
1550
+ const msg = err instanceof Error ? err.message : String(err ?? "");
1551
+ return /is archived[.,]|Restore it before starting new work/i.test(msg);
1552
+ }
1553
+ /**
1554
+ * Restore an archived session (e.g. auto-archived by the Gateway's
1555
+ * active-session-cap) so new work can start. sessions.patch requires
1556
+ * expectedSessionId, so describe first, then patch archived=false.
1557
+ */
1558
+ async unarchiveSession(key) {
1559
+ const canonical = this.canonicalSessionKey(key);
1560
+ try {
1561
+ const info = await this.rpc("sessions.describe", { key: canonical }, 15000);
1562
+ const sessionId = info?.session?.sessionId;
1563
+ if (!sessionId)
1564
+ return false;
1565
+ if (info?.session?.archived !== true)
1566
+ return true; // already active
1567
+ const res = await this.rpc("sessions.patch", { key: canonical, expectedSessionId: sessionId, archived: false }, 15000);
1568
+ console.log(`[OpenClaw] unarchived session ${key}: ${res?.ok === true ? "ok" : "unexpected response"}`);
1569
+ return res?.ok === true;
1570
+ }
1571
+ catch (err) {
1572
+ console.warn(`[OpenClaw] unarchive failed for ${key}:`, err instanceof Error ? err.message : err);
1573
+ return false;
1574
+ }
1575
+ }
1548
1576
  async getSessionStatus(key) {
1549
1577
  return this.rpc("sessions.describe", { key: this.canonicalSessionKey(key) });
1550
1578
  }
@@ -1801,8 +1829,20 @@ export class OpenClawClient {
1801
1829
  const maxLines = Number.isFinite(options.maxLines)
1802
1830
  ? Math.max(1, Math.floor(options.maxLines))
1803
1831
  : undefined;
1832
+ const sendCompact = () => this.rpc("sessions.compact", { key: this.canonicalSessionKey(key), ...(maxLines !== undefined ? { maxLines } : {}) }, 10 * 60 * 1000);
1804
1833
  try {
1805
- return await this.rpc("sessions.compact", { key: this.canonicalSessionKey(key), ...(maxLines !== undefined ? { maxLines } : {}) }, 10 * 60 * 1000);
1834
+ try {
1835
+ return await sendCompact();
1836
+ }
1837
+ catch (err) {
1838
+ if (!this.isArchivedSessionError(err))
1839
+ throw err;
1840
+ console.warn(`[OpenClaw] sessions.compact hit archived session for ${key}; restoring and retrying once`);
1841
+ const restored = await this.unarchiveSession(key);
1842
+ if (!restored)
1843
+ throw err;
1844
+ return await sendCompact();
1845
+ }
1806
1846
  }
1807
1847
  finally {
1808
1848
  const mode = maxLines === undefined ? "semantic" : `maxLines=${maxLines}`;
@@ -2052,13 +2092,28 @@ export class OpenClawClient {
2052
2092
  const sendStartedAt = Date.now();
2053
2093
  try {
2054
2094
  await params.onSendAttempt?.();
2055
- result = await this.rpc("chat.send", {
2095
+ const sendChat = () => this.rpc("chat.send", {
2056
2096
  sessionKey: this.canonicalSessionKey(sk),
2057
2097
  message: params.message,
2058
2098
  attachments: params.attachments,
2059
2099
  deliver: params.deliver ?? false,
2060
2100
  idempotencyKey: randomUUID(),
2061
2101
  });
2102
+ try {
2103
+ result = await sendChat();
2104
+ }
2105
+ catch (err) {
2106
+ // The Gateway auto-archives sessions past the active-session cap; an
2107
+ // archived session refuses new work. Restore it once and retry — the
2108
+ // user should never have to fix this by hand.
2109
+ if (!this.isArchivedSessionError(err))
2110
+ throw err;
2111
+ console.warn(`[OpenClaw] chat.send hit archived session for ${sk}; restoring and retrying once`);
2112
+ const restored = await this.unarchiveSession(sk);
2113
+ if (!restored)
2114
+ throw err;
2115
+ result = await sendChat();
2116
+ }
2062
2117
  }
2063
2118
  finally {
2064
2119
  releaseChatSendSlot();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-lark-multi-agent",
3
- "version": "1.4.13",
3
+ "version": "1.5.1",
4
4
  "description": "Multi-bot Lark/Feishu bridge for OpenClaw, with per-bot model routing and isolated sessions",
5
5
  "type": "module",
6
6
  "scripts": {