openclaw-lark-multi-agent 1.4.12 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,6 +6,7 @@ import { InactiveRunObservation } from "./openclaw-client.js";
6
6
  import { SessionWaitPaused, isWaitTimeout, normalizeSessionRuntimeStatus, formatSessionFooter, FOREGROUND_WAIT_MS } from "./session-status.js";
7
7
  import { LiveStatusController } from "./live-status.js";
8
8
  import { CompactProgressController } from "./compact-progress.js";
9
+ import { errorIdentity } from "./terminal-errors.js";
9
10
  import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "fs";
10
11
  import { basename, extname, join, resolve } from "path";
11
12
  import { getBridgeAttachmentsDir, getDataDir } from "./paths.js";
@@ -43,12 +44,12 @@ function steerInjectionPrefix(en) {
43
44
  || "[New message inserted by the user mid-task; please handle it together with the current task]";
44
45
  return en ? enText : zh;
45
46
  }
46
- // Emergency transcript trim used only when semantic compaction cannot run.
47
- // The Gateway owns the transcript store (JSONL before 2026.8, SQLite after it),
48
- // so LMA must use sessions.compact instead of mutating storage directly.
47
+ // Default transcript-tail trim depth for /compact. Tool-heavy group sessions can
48
+ // easily carry 300+ events of pure tool noise, so keep a generous tail: the
49
+ // Gateway truncates to the last N transcript events. Env override stays.
49
50
  function compactFallbackMaxLines() {
50
- const value = Number(process.env.OPENCLAW_LARK_MULTI_AGENT_COMPACT_FALLBACK_MAX_LINES || 200);
51
- return Number.isFinite(value) ? Math.max(20, Math.floor(value)) : 200;
51
+ const value = Number(process.env.OPENCLAW_LARK_MULTI_AGENT_COMPACT_FALLBACK_MAX_LINES || 800);
52
+ return Number.isFinite(value) ? Math.max(20, Math.floor(value)) : 800;
52
53
  }
53
54
  // After a reply is delivered, if context usage reaches this percent, send the
54
55
  // user a one-time alert to compact. 0 disables the alert.
@@ -117,6 +118,8 @@ export class FeishuBot {
117
118
  * message falls back to a normal run, cancel these before submission so a
118
119
  * later identical steer cannot consume the stale observer/correlation. */
119
120
  pendingSteerCleanups = new Map();
121
+ errorNoticeTimers = new Map();
122
+ errorNoticeChecks = new Map();
120
123
  adminOpenId;
121
124
  locale;
122
125
  configPath;
@@ -209,7 +212,6 @@ export class FeishuBot {
209
212
  const corrected = await this.openclawClient.ensureModel(sessionKey, this.config.model);
210
213
  if (corrected) {
211
214
  console.log(`[${this.config.name}] Model auto-corrected to ${this.config.model}`);
212
- await this.notifyModelDrift(chatId, sessionKey);
213
215
  }
214
216
  return sessionKey;
215
217
  }
@@ -222,7 +224,6 @@ export class FeishuBot {
222
224
  const corrected = await this.openclawClient.ensureModel(sessionKey, this.config.model);
223
225
  if (corrected) {
224
226
  console.log(`[${this.config.name}] Model auto-corrected to ${this.config.model}`);
225
- await this.notifyModelDrift(chatId, sessionKey);
226
227
  }
227
228
  }
228
229
  else {
@@ -249,6 +250,19 @@ export class FeishuBot {
249
250
  this.openclawClient.setVerboseTranscriptDelivery(sessionKey, this.store.getBotVerbose(this.config.name, chatId));
250
251
  await this.openclawClient.subscribeSession(sessionKey, async (text, meta) => {
251
252
  try {
253
+ let provenance = meta?.error;
254
+ if (!provenance && meta?.genericErrorCandidate && meta.runId && meta.sessionKey) {
255
+ provenance = this.store.getErrorNotice(this.config.name, chatId, errorIdentity(meta.sessionKey, meta.runId))?.provenance;
256
+ }
257
+ if (meta?.sourceType === "run_error" || provenance) {
258
+ if (provenance) {
259
+ this.store.observeRunError(this.config.name, chatId, provenance);
260
+ await this.reconcileErrorNotices(chatId);
261
+ }
262
+ else
263
+ console.warn(`[${this.config.name}] Uncorrelated run_error retained in logs; no finality evidence`);
264
+ return; // errors never cancel pending results or complete live cards
265
+ }
252
266
  const sourceType = meta?.sourceType === "verbose_transcript" ? "verbose_transcript" : "assistant_visible";
253
267
  console.log(`[${this.config.name}] ${sourceType === "verbose_transcript" ? "Verbose transcript" : "Proactive message"} for ${chatId.slice(-8)}`);
254
268
  const parsed = this.extractBridgeAttachments(text);
@@ -260,7 +274,7 @@ export class FeishuBot {
260
274
  ? candidateTarget
261
275
  : undefined);
262
276
  try {
263
- await this.enqueueAndDispatchDelivery(chatId, sourceType, this.deliverySourceId(sourceType === "verbose_transcript" ? "verbose" : "proactive", `${Date.now()}:${Math.random()}:${parsed.text.trim()}|${JSON.stringify(parsed.attachments)}`), parsed.text.trim(), parsed.attachments, activeTarget?.messageId, activeTarget ? `trigger:${activeTarget.triggerId}` : undefined, sourceType === "verbose_transcript" ? undefined : this.config.model);
277
+ await this.enqueueAndDispatchDelivery(chatId, sourceType, this.deliverySourceId(sourceType === "verbose_transcript" ? "verbose" : "proactive", `${Date.now()}:${Math.random()}:${parsed.text.trim()}|${JSON.stringify(parsed.attachments)}`), parsed.text.trim(), parsed.attachments, activeTarget?.messageId, activeTarget ? `trigger:${activeTarget.triggerId}` : undefined, sourceType === "verbose_transcript" ? undefined : this.config.model, meta);
264
278
  if (sourceType !== "verbose_transcript" && (parsed.text.trim() || parsed.attachments.length > 0)) {
265
279
  await activeTarget?.liveStatus?.complete().catch(() => { });
266
280
  if (meta?.runId && runTarget)
@@ -363,6 +377,7 @@ export class FeishuBot {
363
377
  }
364
378
  // Re-subscribe to existing sessions
365
379
  await this.ensureSession(chat.chatId);
380
+ drainTasks.push(this.reconcileErrorNotices(chat.chatId));
366
381
  // Drain only messages that were explicitly marked as reply triggers.
367
382
  // Context-only messages should not start an OpenClaw run after restart.
368
383
  const pendingTriggerIds = this.store.getPendingTriggerIds(this.config.name, chat.chatId);
@@ -666,7 +681,7 @@ export class FeishuBot {
666
681
  `━━━━━━━━━━━━━━━━━━`,
667
682
  `桥接层命令(单斜杠,由 openclaw-lark-multi-agent 本地处理)`,
668
683
  `📊 /status — 查看当前模型、Token 用量、Session 状态`,
669
- `🧹 /compact — 压缩当前 bot 的 OpenClaw session`,
684
+ `🧹 /compact [N|maxLines=N|semantic] — 裁剪转录尾部(默认,保留最近事件);N 指定保留条数;semantic 走 LLM 总结`,
670
685
  `🔄 /reset — 重置当前 bot 的 OpenClaw session`,
671
686
  `⏹️ /stop — 强制停止当前 bot 在本聊天的卡死 run,解锁队列`,
672
687
  `🔊 /verbose — 开关当前聊天里的 Tool Call 显示`,
@@ -712,7 +727,8 @@ export class FeishuBot {
712
727
  }
713
728
  if (commandName === "/compact") {
714
729
  await this.ensureSession(chatId);
715
- await this.handleCompactCommand(chatId, messageId);
730
+ const compactArgs = cleanText.trim().split(/\s+/).slice(1).join(" ").trim();
731
+ await this.handleCompactCommand(chatId, messageId, compactArgs);
716
732
  markCommandSynced();
717
733
  return;
718
734
  }
@@ -1343,6 +1359,7 @@ export class FeishuBot {
1343
1359
  }, {
1344
1360
  botName: this.config.name,
1345
1361
  model: this.config.model,
1362
+ thinkingLevel: await this.fetchThinkingLabel(sessionKey),
1346
1363
  locale: this.isEn(chatId) ? "en" : "zh",
1347
1364
  })
1348
1365
  : undefined;
@@ -1871,7 +1888,12 @@ export class FeishuBot {
1871
1888
  const core = en
1872
1889
  ? `${emoji} ${meta.toolCalls} tool call${meta.toolCalls === 1 ? "" : "s"} · ⏱ ${meta.elapsed}`
1873
1890
  : `${emoji} 累计${meta.toolCalls} 次工具调用 · ⏱ 耗时${meta.elapsed}`;
1874
- return meta.model?.trim() ? `${core} · 🧠 ${this.escapeCardText(meta.model.trim())}` : core;
1891
+ if (!meta.model?.trim())
1892
+ return core;
1893
+ const modelLabel = meta.thinkingLevel?.trim()
1894
+ ? `${meta.model.trim()} · ${meta.thinkingLevel.trim()}`
1895
+ : meta.model.trim();
1896
+ return `${core} · 🧠 ${this.escapeCardText(modelLabel)}`;
1875
1897
  }
1876
1898
  /** Best-effort terminal cleanup for status-only rows and legacy v1.4.3
1877
1899
  * outbox entries that once targeted the live-status card. */
@@ -1881,6 +1903,7 @@ export class FeishuBot {
1881
1903
  lines: [],
1882
1904
  elapsed: meta.elapsed,
1883
1905
  model: meta.model,
1906
+ thinkingLevel: meta.thinkingLevel,
1884
1907
  toolCalls: meta.toolCalls,
1885
1908
  noReply: Boolean(meta.noReply),
1886
1909
  state: "done",
@@ -2263,23 +2286,33 @@ export class FeishuBot {
2263
2286
  const sourceId = `discussion-system:${Date.now()}:${Math.random().toString(36).slice(2)}`;
2264
2287
  await this.enqueueAndDispatchDelivery(chatId, "discussion_system", sourceId, text);
2265
2288
  }
2266
- async enqueueAndDispatchDelivery(chatId, sourceType, sourceId, text, attachments = [], replyToMessageId, deliveryKey, finalReplyModel) {
2289
+ async enqueueAndDispatchDelivery(chatId, sourceType, sourceId, text, attachments = [], replyToMessageId, deliveryKey, finalReplyModel, provenance, errorNoticeId) {
2267
2290
  if (!text.trim() && attachments.length === 0)
2268
2291
  return;
2269
2292
  const attachmentsJson = JSON.stringify(attachments);
2270
2293
  const normalizedPayload = `${text.trim()}|${attachmentsJson}`;
2271
2294
  const contentHash = this.stableHash(normalizedPayload);
2272
2295
  const finalDeliveryKey = deliveryKey || sourceId;
2273
- const deliveryMetaJson = finalReplyModel?.trim()
2274
- ? JSON.stringify({ model: finalReplyModel.trim() })
2275
- : "{}";
2296
+ const deliveryMetaJson = JSON.stringify({
2297
+ ...(finalReplyModel?.trim() ? { model: finalReplyModel.trim() } : {}),
2298
+ ...(provenance ? { provenance } : {}), ...(errorNoticeId ? { errorNoticeId } : {}),
2299
+ });
2300
+ const correlatedFinal = sourceType === "assistant_visible" && provenance?.final && Boolean(provenance.runId && provenance.sessionKey);
2301
+ if (correlatedFinal) {
2302
+ const identity = { sessionKey: provenance.sessionKey, runId: provenance.runId };
2303
+ this.store.promoteExactRunFinal(this.config.name, chatId, identity.sessionKey, identity.runId, contentHash, text, attachmentsJson);
2304
+ if (!deliveryKey && this.store.hasExactRunFinalPayload(this.config.name, chatId, identity, text, attachmentsJson)) {
2305
+ await this.dispatchPendingDeliveries(chatId);
2306
+ return;
2307
+ }
2308
+ }
2276
2309
  if (sourceType === "verbose_transcript") {
2277
2310
  if (this.store.hasRecentSimilarDelivery(this.config.name, chatId, contentHash, 60_000, ["verbose_transcript"]))
2278
2311
  return;
2279
2312
  if (this.store.hasRecentOverlappingDelivery(this.config.name, chatId, text, attachmentsJson, 60_000, 8, ["verbose_transcript"]))
2280
2313
  return;
2281
2314
  }
2282
- else if (!deliveryKey) {
2315
+ else if (!deliveryKey && !correlatedFinal) {
2283
2316
  if (this.store.hasRecentSimilarDelivery(this.config.name, chatId, contentHash, 60_000, [sourceType]))
2284
2317
  return;
2285
2318
  if (this.store.hasRecentOverlappingDelivery(this.config.name, chatId, text, attachmentsJson, 60_000, 8, [sourceType]))
@@ -2306,6 +2339,15 @@ export class FeishuBot {
2306
2339
  // answer again on a second card. The original outbox row remains the sole
2307
2340
  // owner of text delivery; a different newly-frozen card is only collapsed.
2308
2341
  const existing = this.store.getDeliveryByKey(this.config.name, chatId, finalDeliveryKey);
2342
+ if (sourceType === "run_error") {
2343
+ // A withdrawn unsent notice may become terminal later. Keep its one
2344
+ // task key; never use the result recovery/correction/attachment paths.
2345
+ if (existing?.status === "failed" && !existing.textDelivered && existing.attempts < DELIVERY_MAX_ATTEMPTS) {
2346
+ this.store.renewUnsentErrorDelivery(existing.id, text, contentHash, deliveryMetaJson);
2347
+ }
2348
+ await this.dispatchPendingDeliveries(chatId);
2349
+ return;
2350
+ }
2309
2351
  // A failed owner must not permanently poison a logical trigger. Re-enqueue
2310
2352
  // the payload under a deterministic recovery key so a later authoritative
2311
2353
  // delivery gets a fresh retry budget.
@@ -2408,6 +2450,124 @@ export class FeishuBot {
2408
2450
  this.lastRealDeliveryAt.set(chatId, Date.now());
2409
2451
  }
2410
2452
  }
2453
+ async inspectErrorNotice(record) {
2454
+ if (this.store.hasRunFinalDelivery(this.config.name, record.chatId, record.provenance, true)) {
2455
+ return { state: "suppressed", reason: "exact_run_final_platform_delivery_confirmed" };
2456
+ }
2457
+ if (this.store.hasRunFinalDelivery(this.config.name, record.chatId, record.provenance, false)) {
2458
+ return { state: "pending", reason: "exact_run_final_delivery_pending" };
2459
+ }
2460
+ let verdict;
2461
+ try {
2462
+ verdict = await this.openclawClient.inspectRunError(record.provenance);
2463
+ }
2464
+ catch {
2465
+ verdict = { state: "pending", reason: "inspection_unavailable" };
2466
+ }
2467
+ // A stop or final may have arrived during the read-only lookup.
2468
+ const latest = this.store.getErrorNotice(this.config.name, record.chatId, record.noticeId);
2469
+ if (latest?.provenance.userStop) {
2470
+ record.provenance.userStop = true;
2471
+ return { state: "stopped", reason: "explicit_bridge_stop" };
2472
+ }
2473
+ if (latest && (latest.provenance.source !== record.provenance.source || latest.provenance.state !== record.provenance.state || latest.provenance.stopReason !== record.provenance.stopReason)) {
2474
+ record.provenance = latest.provenance;
2475
+ record.checks = latest.checks;
2476
+ return { state: "pending", reason: "stronger_terminal_evidence_observed" };
2477
+ }
2478
+ if (this.store.hasRunFinalDelivery(this.config.name, record.chatId, record.provenance, true)) {
2479
+ return { state: "suppressed", reason: "exact_run_final_platform_delivery_confirmed" };
2480
+ }
2481
+ if (this.store.hasRunFinalDelivery(this.config.name, record.chatId, record.provenance, false)) {
2482
+ return { state: "pending", reason: "exact_run_final_delivery_pending" };
2483
+ }
2484
+ return verdict;
2485
+ }
2486
+ deferErrorNotice(record, verdict) {
2487
+ const delays = [15_000, 60_000, 120_000, 300_000, FOREGROUND_WAIT_MS];
2488
+ record.verdict = verdict;
2489
+ // Known-live task/delivery state is not an unknown diagnosis. Continue
2490
+ // low-frequency checks across the Gateway's 30-minute retry window, bounded
2491
+ // by the existing 24-hour observation horizon. Unknown evidence stays capped.
2492
+ if (verdict.reason === "matching_task_or_delivery_continuing")
2493
+ record.hasObservedContinuingTask = true;
2494
+ const continuing = record.hasObservedContinuingTask === true;
2495
+ const budget = continuing ? 300 : 6;
2496
+ record.status = record.checks >= budget || Date.now() - record.createdAt >= 24 * 60 * 60_000 ? "parked" : "pending";
2497
+ record.nextCheckAt = Date.now() + (continuing && record.checks >= 4 ? 300_000 : (delays[Math.max(0, record.checks - 1)] || FOREGROUND_WAIT_MS));
2498
+ this.store.saveErrorNotice(record);
2499
+ this.scheduleErrorNoticeCheck(record.chatId);
2500
+ }
2501
+ scheduleErrorNoticeCheck(chatId) {
2502
+ const previous = this.errorNoticeTimers.get(chatId);
2503
+ if (previous)
2504
+ clearTimeout(previous);
2505
+ this.errorNoticeTimers.delete(chatId);
2506
+ const next = this.store.listPendingErrorNotices(this.config.name, chatId)[0];
2507
+ if (!next)
2508
+ return;
2509
+ const timer = setTimeout(() => {
2510
+ this.errorNoticeTimers.delete(chatId);
2511
+ void this.reconcileErrorNotices(chatId).catch(err => console.warn(`[${this.config.name}] Error notice check unavailable:`, this.errorSummary(err)));
2512
+ }, Math.max(1000, next.nextCheckAt - Date.now()));
2513
+ timer.unref?.();
2514
+ this.errorNoticeTimers.set(chatId, timer);
2515
+ }
2516
+ /** Bounded read-only diagnosis; known live tasks retain low-frequency observation. */
2517
+ async reconcileErrorNotices(chatId) {
2518
+ const running = this.errorNoticeChecks.get(chatId);
2519
+ if (running)
2520
+ return running;
2521
+ const work = async () => {
2522
+ const timer = this.errorNoticeTimers.get(chatId);
2523
+ if (timer)
2524
+ clearTimeout(timer);
2525
+ this.errorNoticeTimers.delete(chatId);
2526
+ for (const snapshot of this.store.listPendingErrorNotices(this.config.name, chatId)) {
2527
+ // Earlier awaits may have observed a stop/stronger envelope for this row.
2528
+ const record = this.store.getErrorNotice(this.config.name, chatId, snapshot.noticeId);
2529
+ if (!record || record.status !== "pending")
2530
+ continue;
2531
+ if (record.nextCheckAt > Date.now())
2532
+ continue;
2533
+ if (record.checks >= (record.hasObservedContinuingTask || record.verdict?.reason === "matching_task_or_delivery_continuing" ? 300 : 6)) {
2534
+ // Normal exhausted checks are already parked. A still-pending row at
2535
+ // the cap can be a crash after budget reservation / before verdict or
2536
+ // outbox creation. Reserve one durable recovery probe, never per boot.
2537
+ if (record.recoveryProbeUsed) {
2538
+ this.deferErrorNotice(record, record.verdict || { state: "pending", reason: "budget_exhausted" });
2539
+ continue;
2540
+ }
2541
+ record.recoveryProbeUsed = true;
2542
+ }
2543
+ record.checks++;
2544
+ // Reserve the budget before awaiting RPC, including crash/disconnect.
2545
+ this.store.saveErrorNotice(record);
2546
+ const verdict = await this.inspectErrorNotice(record);
2547
+ console.log(`[${this.config.name}] Error notice ${record.noticeId}: ${verdict.state}/${verdict.reason}`);
2548
+ if (verdict.state === "pending") {
2549
+ this.deferErrorNotice(record, verdict);
2550
+ continue;
2551
+ }
2552
+ record.verdict = verdict;
2553
+ this.store.saveErrorNotice(record);
2554
+ if (verdict.state === "terminal" && verdict.terminalKey && verdict.text) {
2555
+ // Independent identity: never take the result's trigger/final key.
2556
+ await this.enqueueAndDispatchDelivery(chatId, "run_error", verdict.terminalKey, verdict.text, [], undefined, verdict.terminalKey, undefined, { sourceType: "run_error", runId: record.provenance.runId, error: record.provenance, terminalEvidence: verdict }, record.noticeId);
2557
+ // The send-time recheck may have parked or suppressed the observation.
2558
+ const refreshed = this.store.getErrorNotice(this.config.name, chatId, record.noticeId);
2559
+ if (refreshed?.verdict && ["pending", "suppressed", "stopped"].includes(refreshed.verdict.state))
2560
+ continue;
2561
+ }
2562
+ record.status = verdict.state;
2563
+ this.store.saveErrorNotice(record);
2564
+ }
2565
+ this.scheduleErrorNoticeCheck(chatId);
2566
+ };
2567
+ const promise = work().finally(() => { this.errorNoticeChecks.delete(chatId); });
2568
+ this.errorNoticeChecks.set(chatId, promise);
2569
+ return promise;
2570
+ }
2411
2571
  scheduleDeliveryRetry(chatId, attempt) {
2412
2572
  if (this.deliveryRetryTimers.has(chatId))
2413
2573
  return;
@@ -2454,7 +2614,32 @@ export class FeishuBot {
2454
2614
  const finalStatus = needsStatus ? formatSessionFooter(await this.readSessionStatus(chatId)) : undefined;
2455
2615
  if (needsStatus && !finalModel)
2456
2616
  finalModel = this.config.model;
2457
- if (shouldReplyToSource) {
2617
+ if (item.sourceType === "run_error") {
2618
+ const meta = JSON.parse(item.deliveryMetaJson || "{}");
2619
+ const record = this.store.getErrorNotice(this.config.name, chatId, meta.errorNoticeId || "");
2620
+ const verdict = record ? await this.inspectErrorNotice(record) : { state: "pending", reason: "missing_persisted_provenance" };
2621
+ if (verdict.state !== "terminal" || verdict.terminalKey !== item.deliveryKey || verdict.text !== item.content) {
2622
+ this.store.withdrawErrorDelivery(item.id);
2623
+ if (record) {
2624
+ if (verdict.state === "pending" || verdict.state === "terminal")
2625
+ this.deferErrorNotice(record, { ...verdict, state: "pending" });
2626
+ else {
2627
+ record.status = verdict.state;
2628
+ record.verdict = verdict;
2629
+ this.store.saveErrorNotice(record);
2630
+ }
2631
+ }
2632
+ return;
2633
+ }
2634
+ record.verdict = verdict;
2635
+ this.store.saveErrorNotice(record);
2636
+ }
2637
+ if (item.sourceType === "run_error") {
2638
+ // Exactly one platform operation per guarded attempt. A card/text
2639
+ // fallback must not bypass a fresh finality check.
2640
+ await this.sendErrorNotice(chatId, item.content);
2641
+ }
2642
+ else if (shouldReplyToSource) {
2458
2643
  try {
2459
2644
  await this.replyFinalMessage(replyTarget, item.content, finalModel, finalStatus);
2460
2645
  }
@@ -2515,6 +2700,8 @@ export class FeishuBot {
2515
2700
  return;
2516
2701
  }
2517
2702
  this.store.markDeliveryFailed(item.id);
2703
+ if (item.sourceType === "run_error")
2704
+ return;
2518
2705
  const label = failureStage === "attachment" ? "附件发送失败" : "最终回复发送失败";
2519
2706
  const errorText = `⚠️ ${label}(已重试 ${DELIVERY_MAX_ATTEMPTS} 次):${this.errorSummary(err)}`;
2520
2707
  const replyTarget = item.replyToMessageId || replyToMessageId;
@@ -2602,6 +2789,24 @@ export class FeishuBot {
2602
2789
  const bot = Array.from(FeishuBot.allBots.values()).find((candidate) => candidate.store === this.store && candidate.config.name === chairman && this.isBotAvailableInChat(candidate, chatId));
2603
2790
  return bot ? this.asDiscussionParticipant(bot, chatId) : undefined;
2604
2791
  }
2792
+ /** Resolve the session's current thinking label for the live-status footer.
2793
+ * Prefers a live session override (set via //think / sessions.patch), falls
2794
+ * back to the session's default, then the bot's config-level thinking. */
2795
+ async fetchThinkingLabel(sessionKey) {
2796
+ try {
2797
+ const info = await this.openclawClient.getSessionInfo(sessionKey);
2798
+ const s = info?.session;
2799
+ const label = (typeof s?.thinkingLevel === "string" && s.thinkingLevel.trim())
2800
+ || (typeof s?.thinkingDefault === "string" && s.thinkingDefault.trim())
2801
+ || (typeof this.config.thinking === "string" && this.config.thinking.trim())
2802
+ || "";
2803
+ return label || undefined;
2804
+ }
2805
+ catch {
2806
+ const cfg = typeof this.config.thinking === "string" ? this.config.thinking.trim() : "";
2807
+ return cfg || undefined;
2808
+ }
2809
+ }
2605
2810
  asDiscussionParticipant(bot, chatId) {
2606
2811
  return {
2607
2812
  name: bot.config.name,
@@ -2620,6 +2825,7 @@ export class FeishuBot {
2620
2825
  }, {
2621
2826
  botName: this.config.name,
2622
2827
  model: this.config.model,
2828
+ thinkingLevel: await this.fetchThinkingLabel(sessionKey),
2623
2829
  locale: this.isEn(chatId) ? "en" : "zh",
2624
2830
  })
2625
2831
  : undefined;
@@ -2719,6 +2925,7 @@ export class FeishuBot {
2719
2925
  toolCalls: view.toolCalls,
2720
2926
  elapsed: view.elapsed,
2721
2927
  model: view.model,
2928
+ thinkingLevel: view.thinkingLevel,
2722
2929
  locale: en ? "en" : "zh",
2723
2930
  }, statusEmoji);
2724
2931
  return {
@@ -2754,8 +2961,12 @@ export class FeishuBot {
2754
2961
  // Keep the active model visible while the run is in progress or failed.
2755
2962
  // The final reply also keeps model attribution so it remains self-contained.
2756
2963
  const footerBits = [en ? `⏱ ${view.elapsed}` : `⏱ 已用 ${view.elapsed}`];
2757
- if (view.model?.trim())
2758
- footerBits.push(`🧠 ${this.escapeCardText(view.model.trim())}`);
2964
+ if (view.model?.trim()) {
2965
+ const modelBit = view.thinkingLevel?.trim()
2966
+ ? `🧠 ${this.escapeCardText(view.model.trim())} · ${this.escapeCardText(view.thinkingLevel.trim())}`
2967
+ : `🧠 ${this.escapeCardText(view.model.trim())}`;
2968
+ footerBits.push(modelBit);
2969
+ }
2759
2970
  elements.push({ tag: "markdown", content: `<font color='grey'>${footerBits.join(" · ")}</font>` });
2760
2971
  const template = view.state === "failed" ? "orange" : "blue";
2761
2972
  return {
@@ -2842,7 +3053,9 @@ export class FeishuBot {
2842
3053
  // Running: phase line + ticking elapsed footer.
2843
3054
  const phaseLine = view.phase === "transcript-trim"
2844
3055
  ? (en ? "🧹 Semantic compaction unavailable — trimming transcript safely…" : "🧹 语义压缩不可用,正在安全裁剪转录…")
2845
- : (en ? "🧹 Compacting session…" : "🧹 正在压缩 session…");
3056
+ : view.phase === "trim"
3057
+ ? (en ? "🧹 Trimming transcript tail (keeping the most recent events)…" : "🧹 正在裁剪转录尾部(保留最近的事件)…")
3058
+ : (en ? "🧹 Compacting session…" : "🧹 正在压缩 session…");
2846
3059
  const footer = en ? `⏱ ${view.elapsed}` : `⏱ 已用 ${view.elapsed}`;
2847
3060
  return {
2848
3061
  schema: "2.0",
@@ -3280,6 +3493,16 @@ export class FeishuBot {
3280
3493
  /**
3281
3494
  * Send a proactive message to a chat (not a reply).
3282
3495
  */
3496
+ async sendErrorNotice(chatId, text) {
3497
+ const res = await this.client.im.message.create({
3498
+ params: { receive_id_type: "chat_id" },
3499
+ data: { receive_id: chatId, msg_type: "text", content: JSON.stringify({ text }) },
3500
+ });
3501
+ const id = res?.data?.message_id || res?.message_id;
3502
+ if (!id)
3503
+ throw new Error("Error notice send returned no confirmed message id");
3504
+ return id;
3505
+ }
3283
3506
  async sendFinalMessage(chatId, text, model, status) {
3284
3507
  if (!model?.trim())
3285
3508
  return this.sendMessage(chatId, text);
@@ -3609,16 +3832,37 @@ export class FeishuBot {
3609
3832
  * Handle /compact command: compress session context.
3610
3833
  */
3611
3834
  /**
3612
- * Compact a session with a Gateway-owned fallback chain:
3613
- * 1. Semantic compaction through `sessions.compact`.
3614
- * 2. If summarization cannot run, ask the same RPC to retain only a bounded
3615
- * transcript tail via `maxLines`. This works with legacy JSONL and the
3616
- * SQLite session store introduced in OpenClaw 2026.8.x, and preserves
3617
- * OpenClaw's write locks, transcript invariants, and migration ownership.
3835
+ * Compact a session via Gateway-owned RPCs. LMA deliberately never opens or
3836
+ * rewrites OpenClaw transcript storage.
3618
3837
  *
3619
- * LMA deliberately never opens or rewrites OpenClaw transcript storage.
3838
+ * Modes (kept strictly separate so the flows are never mixed):
3839
+ * - "trim" (default): `sessions.compact` with `maxLines` — permanently keeps
3840
+ * only the last N transcript events. Model-free, fast, never times out on
3841
+ * oversized sessions.
3842
+ * - "semantic": LLM summarization via the same RPC; on failure falls back to
3843
+ * a trim so the session is still reclaimed.
3620
3844
  */
3621
- async compactWithFallback(sessionKey, onPhase) {
3845
+ async compactWithFallback(sessionKey, onPhase, opts = {}) {
3846
+ const mode = opts.mode === "semantic" ? "semantic" : "trim";
3847
+ const trimMaxLines = Number.isFinite(opts.maxLines) && opts.maxLines >= 20
3848
+ ? Math.floor(opts.maxLines)
3849
+ : compactFallbackMaxLines();
3850
+ if (mode === "trim") {
3851
+ onPhase?.("trim");
3852
+ try {
3853
+ const trimmed = await this.openclawClient.compactSession(sessionKey, { maxLines: trimMaxLines });
3854
+ if (trimmed?.compacted === true) {
3855
+ const kept = typeof trimmed?.kept === "number" ? trimmed.kept : trimMaxLines;
3856
+ return { compacted: true, method: "transcript-trim", detail: `kept ${kept} transcript events` };
3857
+ }
3858
+ const trimReason = typeof trimmed?.reason === "string" ? trimmed.reason : undefined;
3859
+ return { compacted: false, method: "none", reason: trimReason || "transcript trim made no change" };
3860
+ }
3861
+ catch (err) {
3862
+ return { compacted: false, method: "none", reason: err.message };
3863
+ }
3864
+ }
3865
+ // semantic mode: LLM summarize first, trim as fallback.
3622
3866
  let nativeReason;
3623
3867
  try {
3624
3868
  const res = await this.openclawClient.compactSession(sessionKey);
@@ -3630,11 +3874,10 @@ export class FeishuBot {
3630
3874
  nativeReason = err.message;
3631
3875
  }
3632
3876
  onPhase?.("transcript-trim");
3633
- const maxLines = compactFallbackMaxLines();
3634
3877
  try {
3635
- const trimmed = await this.openclawClient.compactSession(sessionKey, { maxLines });
3878
+ const trimmed = await this.openclawClient.compactSession(sessionKey, { maxLines: trimMaxLines });
3636
3879
  if (trimmed?.compacted === true) {
3637
- const kept = typeof trimmed?.kept === "number" ? trimmed.kept : maxLines;
3880
+ const kept = typeof trimmed?.kept === "number" ? trimmed.kept : trimMaxLines;
3638
3881
  return {
3639
3882
  compacted: true,
3640
3883
  method: "transcript-trim",
@@ -3659,25 +3902,42 @@ export class FeishuBot {
3659
3902
  };
3660
3903
  }
3661
3904
  }
3662
- async handleCompactCommand(chatId, messageId) {
3905
+ async handleCompactCommand(chatId, messageId, args = "") {
3663
3906
  const sessionKey = this.getSessionKey(chatId);
3664
3907
  const en = this.isEn(chatId);
3665
- // Large sessions can take tens of seconds to compact (native compaction may
3666
- // even time out before the transcript-trim fallback kicks in). Show a ticking
3667
- // "compacting…" card so the user can SEE it is working instead of staring at
3668
- // nothing and assuming nothing is happening. The card is created lazily, so
3669
- // a fast compaction that finishes quickly never flashes a card at all.
3908
+ // Argument parsing — modes are strictly separated:
3909
+ // /compact → transcript tail trim (default, model-free)
3910
+ // /compact semantic → LLM summarization (trim fallback on failure)
3911
+ // /compact 500 → tail trim keeping the last 500 events
3912
+ // /compact maxLines=500 → same as above
3913
+ const argTokens = args.split(/\s+/).filter(Boolean);
3914
+ let mode = "trim";
3915
+ let maxLines;
3916
+ for (const tok of argTokens) {
3917
+ const semanticMatch = /^(semantic|summarize|llm)$/i.exec(tok);
3918
+ if (semanticMatch) {
3919
+ mode = "semantic";
3920
+ continue;
3921
+ }
3922
+ const linesMatch = /^(?:maxlines|max_lines)[=:](\d{2,})$/i.exec(tok) || /^(\d{2,})$/.exec(tok);
3923
+ if (linesMatch) {
3924
+ maxLines = Number(linesMatch[1] || linesMatch[2]);
3925
+ continue;
3926
+ }
3927
+ }
3670
3928
  const progress = new CompactProgressController({
3671
3929
  create: (view) => this.sendOrdered(chatId, () => this.replyCompactCard(messageId, view, chatId)),
3672
3930
  edit: (id, view) => this.sendOrdered(chatId, () => this.patchCompactCard(id, view, chatId)),
3673
3931
  warn: (msg, err) => console.warn(`[${this.config.name}] ${msg}`, err instanceof Error ? err.message : err),
3674
3932
  }, { locale: en ? "en" : "zh" });
3933
+ if (mode === "trim")
3934
+ progress.toTrim();
3675
3935
  progress.start();
3676
3936
  try {
3677
3937
  const r = await this.compactWithFallback(sessionKey, (phase) => {
3678
3938
  if (phase === "transcript-trim")
3679
3939
  void progress.toTranscriptTrim().catch(() => { });
3680
- });
3940
+ }, { mode, maxLines });
3681
3941
  if (r.compacted) {
3682
3942
  // If a card was shown, patch it in place; otherwise (fast finish, no card)
3683
3943
  // fall back to a normal reply so the user still gets confirmation.
@@ -3840,9 +4100,6 @@ export class FeishuBot {
3840
4100
  console.warn(`[${this.config.name}] Failed to fetch chat info:`, err.message);
3841
4101
  }
3842
4102
  }
3843
- /**
3844
- * Send a model-drift notification to the affected chat.
3845
- */
3846
4103
  async hydrateInlineImageKeys(text, messageId) {
3847
4104
  const imageKeyPattern = /\[Image: (img_[^\]\n]+)\]/g;
3848
4105
  const replacements = [];
@@ -4070,24 +4327,4 @@ export class FeishuBot {
4070
4327
  }
4071
4328
  return this.cleanMentions(parts.join(" "));
4072
4329
  }
4073
- async notifyModelDrift(chatId, _sessionKey) {
4074
- try {
4075
- const chatInfo = this.store.getChatInfo(chatId);
4076
- const chatLabel = chatInfo?.chatName || chatId.slice(-8);
4077
- await this.client.im.message.create({
4078
- params: { receive_id_type: "chat_id" },
4079
- data: {
4080
- receive_id: chatId,
4081
- content: JSON.stringify({
4082
- text: `⚠️ 模型漂移已自动纠正\n期望: ${this.config.model}\n已恢复`,
4083
- }),
4084
- msg_type: "text",
4085
- },
4086
- });
4087
- console.log(`[${this.config.name}] Drift notification sent to ${chatLabel}`);
4088
- }
4089
- catch (err) {
4090
- console.warn(`[${this.config.name}] Failed to notify drift:`, err.message);
4091
- }
4092
- }
4093
4330
  }
@@ -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,