openclaw-lark-multi-agent 1.0.20 → 1.0.22

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.
@@ -0,0 +1,56 @@
1
+ import type { ProgressEvent } from "./openclaw-client";
2
+ export type LiveStatusCallbacks = {
3
+ create: (text: string) => Promise<string | undefined>;
4
+ edit: (messageId: string, text: string) => Promise<void>;
5
+ warn?: (message: string, err?: unknown) => void;
6
+ };
7
+ export type LiveStatusOptions = {
8
+ botName: string;
9
+ locale?: "zh" | "en";
10
+ delayMs?: number;
11
+ maxChars?: number;
12
+ maxEdits?: number;
13
+ disableHint?: string;
14
+ };
15
+ export declare class LiveStatusController {
16
+ private readonly callbacks;
17
+ private readonly opts;
18
+ private messageId?;
19
+ private lastSentText;
20
+ private detail;
21
+ /** Number of edits already issued against the live message (excludes create). */
22
+ private editCount;
23
+ private createTimer?;
24
+ private createPromise?;
25
+ private finalized;
26
+ private disabled;
27
+ /** True after the running-state budget is spent; future progress is ignored. */
28
+ private runningLimitReached;
29
+ constructor(callbacks: LiveStatusCallbacks, opts: LiveStatusOptions);
30
+ get id(): string | undefined;
31
+ start(initialDetail?: string): void;
32
+ progress(event: ProgressEvent | string): Promise<void>;
33
+ /**
34
+ * Finish the live status when the real (final) reply is delivered separately.
35
+ * The final answer is sent by the normal interactive-card path, so the status
36
+ * message must NOT be overwritten with the answer (text type does not render
37
+ * Markdown). Feishu renders message deletion as a visible "recalled a message"
38
+ * tombstone, so keep the status message and mark it done instead.
39
+ */
40
+ complete(): Promise<void>;
41
+ /**
42
+ * Finish the live status when the run failed and the error is delivered by the
43
+ * normal path. Use a neutral/negative marker so it does not contradict the
44
+ * separately delivered error message.
45
+ */
46
+ fail(): Promise<void>;
47
+ dispose(): void;
48
+ private stopTimers;
49
+ private ensureCreatedNow;
50
+ private ensureCreated;
51
+ private editRunningStatus;
52
+ private safeEdit;
53
+ private formatToolStart;
54
+ private formatStatus;
55
+ private formatLimitNotice;
56
+ }
@@ -0,0 +1,184 @@
1
+ const DEFAULT_DELAY_MS = Number(process.env.OPENCLAW_LARK_MULTI_AGENT_LIVE_STATUS_DELAY_MS || 800);
2
+ const DEFAULT_MAX_CHARS = Number(process.env.OPENCLAW_LARK_MULTI_AGENT_LIVE_STATUS_MAX_CHARS || 120);
3
+ // Feishu caps a single message at 20 edits (code=230072 once exhausted). Keep the
4
+ // final edit for the completion marker; running-state updates may consume at most
5
+ // maxEdits - 1, with the last running edit reserved for an explicit limit notice.
6
+ const DEFAULT_MAX_EDITS = Number(process.env.OPENCLAW_LARK_MULTI_AGENT_LIVE_STATUS_MAX_EDITS || 20);
7
+ export class LiveStatusController {
8
+ callbacks;
9
+ opts;
10
+ messageId;
11
+ lastSentText = "";
12
+ detail = "";
13
+ /** Number of edits already issued against the live message (excludes create). */
14
+ editCount = 0;
15
+ createTimer;
16
+ createPromise;
17
+ finalized = false;
18
+ disabled = false;
19
+ /** True after the running-state budget is spent; future progress is ignored. */
20
+ runningLimitReached = false;
21
+ constructor(callbacks, opts) {
22
+ this.callbacks = callbacks;
23
+ this.opts = opts;
24
+ }
25
+ get id() { return this.messageId; }
26
+ start(initialDetail) {
27
+ if (this.disabled || this.finalized || this.createTimer || this.messageId)
28
+ return;
29
+ this.detail = initialDetail || "";
30
+ this.createTimer = setTimeout(() => {
31
+ this.createTimer = undefined;
32
+ this.createPromise = this.ensureCreated();
33
+ }, this.opts.delayMs ?? DEFAULT_DELAY_MS);
34
+ }
35
+ async progress(event) {
36
+ if (this.disabled || this.finalized || this.runningLimitReached)
37
+ return;
38
+ // Only tool-call start is meaningful enough to spend one of Feishu's limited
39
+ // edit slots. Ignore verbose assistant notes, lifecycle ticks, tool end, and
40
+ // tool error; the final/error answer is delivered by the normal message path.
41
+ if (typeof event !== "string") {
42
+ if (event.kind !== "tool" || event.phase !== "start")
43
+ return;
44
+ this.detail = this.formatToolStart(event);
45
+ }
46
+ else {
47
+ // String progress is kept for tests/backwards compatibility, but in normal
48
+ // runtime OpenClaw progress is structured and only tool start passes above.
49
+ this.detail = event.trim();
50
+ }
51
+ if (!this.detail)
52
+ return;
53
+ await this.ensureCreatedNow();
54
+ await this.editRunningStatus();
55
+ }
56
+ /**
57
+ * Finish the live status when the real (final) reply is delivered separately.
58
+ * The final answer is sent by the normal interactive-card path, so the status
59
+ * message must NOT be overwritten with the answer (text type does not render
60
+ * Markdown). Feishu renders message deletion as a visible "recalled a message"
61
+ * tombstone, so keep the status message and mark it done instead.
62
+ */
63
+ async complete() {
64
+ this.finalized = true;
65
+ this.stopTimers();
66
+ if (this.createPromise)
67
+ await this.createPromise.catch(() => { });
68
+ if (!this.messageId || this.disabled)
69
+ return;
70
+ const doneText = this.opts.locale === "en"
71
+ ? `\u2705 ${this.opts.botName} done`
72
+ : `\u2705 ${this.opts.botName} \u5df2\u5b8c\u6210`;
73
+ await this.safeEdit(doneText, true);
74
+ }
75
+ /**
76
+ * Finish the live status when the run failed and the error is delivered by the
77
+ * normal path. Use a neutral/negative marker so it does not contradict the
78
+ * separately delivered error message.
79
+ */
80
+ async fail() {
81
+ this.finalized = true;
82
+ this.stopTimers();
83
+ if (this.createPromise)
84
+ await this.createPromise.catch(() => { });
85
+ if (!this.messageId || this.disabled)
86
+ return;
87
+ const failedText = this.opts.locale === "en"
88
+ ? `\u26A0\uFE0F ${this.opts.botName} stopped`
89
+ : `\u26A0\uFE0F ${this.opts.botName} \u6267\u884c\u4e2d\u65ad`;
90
+ await this.safeEdit(failedText, true);
91
+ }
92
+ dispose() {
93
+ this.finalized = true;
94
+ this.stopTimers();
95
+ }
96
+ stopTimers() {
97
+ if (this.createTimer) {
98
+ clearTimeout(this.createTimer);
99
+ this.createTimer = undefined;
100
+ }
101
+ }
102
+ async ensureCreatedNow() {
103
+ if (this.createTimer) {
104
+ clearTimeout(this.createTimer);
105
+ this.createTimer = undefined;
106
+ this.createPromise = this.ensureCreated();
107
+ }
108
+ if (this.createPromise)
109
+ await this.createPromise.catch(() => { });
110
+ }
111
+ async ensureCreated() {
112
+ if (this.disabled || this.finalized || this.messageId)
113
+ return;
114
+ const text = this.formatStatus(true);
115
+ try {
116
+ const id = await this.callbacks.create(text);
117
+ if (!id) {
118
+ this.disabled = true;
119
+ return;
120
+ }
121
+ this.messageId = id;
122
+ this.lastSentText = text;
123
+ }
124
+ catch (err) {
125
+ this.disabled = true;
126
+ this.callbacks.warn?.("live status create failed", err);
127
+ }
128
+ }
129
+ async editRunningStatus() {
130
+ if (!this.messageId || this.disabled || this.finalized || this.runningLimitReached)
131
+ return;
132
+ const maxEdits = this.opts.maxEdits ?? DEFAULT_MAX_EDITS;
133
+ const runningBudget = Math.max(0, maxEdits - 1); // reserve final edit for complete()
134
+ if (this.editCount >= runningBudget)
135
+ return;
136
+ const isLimitNotice = this.editCount === runningBudget - 1;
137
+ const text = isLimitNotice ? this.formatLimitNotice() : this.formatStatus();
138
+ const ok = await this.safeEdit(text);
139
+ if (ok && isLimitNotice)
140
+ this.runningLimitReached = true;
141
+ }
142
+ async safeEdit(text, force = false) {
143
+ if (!this.messageId || this.disabled)
144
+ return false;
145
+ if (!force && this.finalized)
146
+ return false;
147
+ if (!force && text === this.lastSentText)
148
+ return true;
149
+ try {
150
+ await this.callbacks.edit(this.messageId, text);
151
+ this.lastSentText = text;
152
+ this.editCount++;
153
+ return true;
154
+ }
155
+ catch (err) {
156
+ this.disabled = true;
157
+ this.stopTimers();
158
+ this.callbacks.warn?.("live status edit failed", err);
159
+ return false;
160
+ }
161
+ }
162
+ formatToolStart(event) {
163
+ const raw = event.text || event.name || "";
164
+ const prefix = `${event.name} start`;
165
+ let detail = raw.startsWith(prefix) ? raw.slice(prefix.length).trim() : raw.trim();
166
+ if (detail.startsWith(":"))
167
+ detail = detail.slice(1).trim();
168
+ return detail ? `${event.name}: ${detail}` : event.name;
169
+ }
170
+ formatStatus(includeDisableHint = false) {
171
+ const maxChars = this.opts.maxChars ?? DEFAULT_MAX_CHARS;
172
+ const clean = (this.detail || "").replace(/\s+/g, " ").trim();
173
+ const clipped = clean.length > maxChars ? `${clean.slice(0, Math.max(0, maxChars - 1))}\u2026` : clean;
174
+ const base = !clipped
175
+ ? (this.opts.locale === "en" ? `${this.opts.botName} is working` : `${this.opts.botName} \u6b63\u5728\u6267\u884c`)
176
+ : (this.opts.locale === "en" ? `${this.opts.botName} is working: ${clipped}` : `${this.opts.botName} \u6b63\u5728\u6267\u884c\uff1a${clipped}`);
177
+ return includeDisableHint && this.opts.disableHint ? `${base}\n${this.opts.disableHint}` : base;
178
+ }
179
+ formatLimitNotice() {
180
+ return this.opts.locale === "en"
181
+ ? `${this.opts.botName} reached the update limit and is still running`
182
+ : `${this.opts.botName} \u5df2\u8fbe\u66f4\u65b0\u9650\u5236\uff0c\u6b63\u5728\u6301\u7eed\u6267\u884c\u4e2d`;
183
+ }
184
+ }
@@ -72,6 +72,10 @@ export declare class MessageStore {
72
72
  getPendingDeliveries(chatId?: string, botName?: string, maxCount?: number): DeliveryOutboxItem[];
73
73
  hasRecentSimilarDelivery(botName: string, chatId: string, contentHash: string, windowMs: number, sourceTypes?: string[]): boolean;
74
74
  hasRecentOverlappingDelivery(botName: string, chatId: string, content: string, attachmentsJson: string, windowMs: number, minShortLength?: number, sourceTypes?: string[]): boolean;
75
+ resetStaleDeliveries(botName: string, staleMs: number, maxAttempts: number): {
76
+ restored: number;
77
+ failed: number;
78
+ };
75
79
  claimDelivery(id: number): boolean;
76
80
  markDeliveryDelivered(id: number): void;
77
81
  markDeliveryFailed(id: number): void;
@@ -115,6 +119,8 @@ export declare class MessageStore {
115
119
  getChairmanBot(chatId: string): string;
116
120
  setBotVerbose(botName: string, chatId: string, verbose: boolean): void;
117
121
  getBotVerbose(botName: string, chatId: string): boolean;
122
+ setBotLiveStatus(botName: string, chatId: string, on: boolean): void;
123
+ getBotLiveStatus(botName: string, chatId: string): boolean;
118
124
  setBotMode(botName: string, chatId: string, mode: BotChatMode): void;
119
125
  getBotMode(botName: string, chatId: string): BotChatMode;
120
126
  setBotFreeDiscussion(botName: string, chatId: string, on: boolean): void;
@@ -104,6 +104,7 @@ export class MessageStore {
104
104
  bot_name TEXT NOT NULL,
105
105
  chat_id TEXT NOT NULL,
106
106
  verbose INTEGER NOT NULL DEFAULT 0,
107
+ live_status INTEGER NOT NULL DEFAULT 1,
107
108
  free_discussion INTEGER NOT NULL DEFAULT 0,
108
109
  mode TEXT NOT NULL DEFAULT 'normal',
109
110
  updated_at INTEGER NOT NULL DEFAULT 0,
@@ -135,6 +136,13 @@ export class MessageStore {
135
136
  );
136
137
  CREATE INDEX IF NOT EXISTS idx_delivery_outbox_pending ON delivery_outbox(status, created_at);
137
138
  `);
139
+ // Migration: add per-bot live status toggle if missing
140
+ try {
141
+ this.db.exec(`ALTER TABLE bot_chat_settings ADD COLUMN live_status INTEGER NOT NULL DEFAULT 1`);
142
+ }
143
+ catch {
144
+ // Column already exists
145
+ }
138
146
  // Migration: add delivery_outbox delivery key columns if missing
139
147
  try {
140
148
  this.db.exec(`ALTER TABLE delivery_outbox ADD COLUMN delivery_key TEXT NOT NULL DEFAULT ''`);
@@ -436,6 +444,20 @@ export class MessageStore {
436
444
  }
437
445
  return false;
438
446
  }
447
+ resetStaleDeliveries(botName, staleMs, maxAttempts) {
448
+ const cutoff = Date.now() - staleMs;
449
+ const fail = this.db.prepare(`
450
+ UPDATE delivery_outbox
451
+ SET status = 'failed', updated_at = ?
452
+ WHERE bot_name = ? AND status = 'delivering' AND updated_at < ? AND attempts >= ?
453
+ `).run(Date.now(), botName, cutoff, maxAttempts);
454
+ const restore = this.db.prepare(`
455
+ UPDATE delivery_outbox
456
+ SET status = 'pending', updated_at = ?
457
+ WHERE bot_name = ? AND status = 'delivering' AND updated_at < ? AND attempts < ?
458
+ `).run(Date.now(), botName, cutoff, maxAttempts);
459
+ return { restored: restore.changes, failed: fail.changes };
460
+ }
439
461
  claimDelivery(id) {
440
462
  const result = this.db.prepare(`
441
463
  UPDATE delivery_outbox SET status = 'delivering', attempts = attempts + 1, updated_at = ? WHERE id = ? AND status = 'pending'
@@ -680,6 +702,22 @@ export class MessageStore {
680
702
  `).get(botName, chatId);
681
703
  return !!row?.verbose;
682
704
  }
705
+ setBotLiveStatus(botName, chatId, on) {
706
+ this.db.prepare(`
707
+ INSERT INTO bot_chat_settings (bot_name, chat_id, live_status, updated_at)
708
+ VALUES (?, ?, ?, ?)
709
+ ON CONFLICT (bot_name, chat_id) DO UPDATE SET
710
+ live_status = excluded.live_status,
711
+ updated_at = excluded.updated_at
712
+ `).run(botName, chatId, on ? 1 : 0, Date.now());
713
+ }
714
+ getBotLiveStatus(botName, chatId) {
715
+ const row = this.db.prepare(`
716
+ SELECT live_status FROM bot_chat_settings
717
+ WHERE bot_name = ? AND chat_id = ?
718
+ `).get(botName, chatId);
719
+ return row ? row.live_status !== 0 : true;
720
+ }
683
721
  setBotMode(botName, chatId, mode) {
684
722
  const freeDiscussion = mode === "free" ? 1 : 0;
685
723
  this.db.prepare(`
@@ -1,5 +1,17 @@
1
1
  import { OpenClawConfig } from "./config.js";
2
2
  import { ChatMessage } from "./message-store.js";
3
+ export type ProgressEvent = {
4
+ kind: "tool";
5
+ phase: "start" | "end" | "error";
6
+ name: string;
7
+ text: string;
8
+ } | {
9
+ kind: "assistant_note";
10
+ text: string;
11
+ } | {
12
+ kind: "lifecycle";
13
+ text: string;
14
+ };
3
15
  export type ChatAttachment = {
4
16
  type?: string;
5
17
  mimeType?: string;
@@ -26,6 +38,7 @@ export declare class OpenClawClient {
26
38
  private toolEventCallbacks;
27
39
  private lastToolNamesByItemId;
28
40
  private sessionMessageCallbacks;
41
+ private progressCallbacks;
29
42
  /** Sessions where verbose mode should deliver visible transcript text even when mixed with tool blocks. */
30
43
  private verboseTranscriptSessions;
31
44
  private verboseAssistantTimers;
@@ -138,6 +151,7 @@ export declare class OpenClawClient {
138
151
  onSendAttempt?: () => void | Promise<void>;
139
152
  /** Called after chat.send RPC succeeds and OpenClaw has accepted the user message. */
140
153
  onSubmitted?: (runId: string) => void | Promise<void>;
154
+ onProgress?: (event: ProgressEvent) => void | Promise<void>;
141
155
  }): Promise<string>;
142
156
  private shouldInjectBridgeAttachmentHint;
143
157
  private bridgeAttachmentHint;
@@ -170,6 +184,7 @@ export declare class OpenClawClient {
170
184
  onSendAttempt?: () => void | Promise<void>;
171
185
  /** Called after chat.send RPC succeeds and OpenClaw has accepted the combined message. */
172
186
  onSubmitted?: (runId: string) => void | Promise<void>;
187
+ onProgress?: (event: ProgressEvent) => void | Promise<void>;
173
188
  }): Promise<string>;
174
189
  private formatContextLines;
175
190
  private writeContextSyncFile;
@@ -29,6 +29,7 @@ export class OpenClawClient {
29
29
  toolEventCallbacks = new Map();
30
30
  lastToolNamesByItemId = new Map();
31
31
  sessionMessageCallbacks = new Map();
32
+ progressCallbacks = new Map();
32
33
  /** Sessions where verbose mode should deliver visible transcript text even when mixed with tool blocks. */
33
34
  verboseTranscriptSessions = new Set();
34
35
  verboseAssistantTimers = new Map();
@@ -239,11 +240,17 @@ export class OpenClawClient {
239
240
  this.flushVerboseAssistantState(rawKey);
240
241
  this.clearVerboseAssistantState(rawKey);
241
242
  }
242
- if (toolCb && data.kind === "tool" && data.name && (data.phase === "start" || data.phase === "end" || data.phase === "error")) {
243
+ if (data.kind === "tool" && data.name && (data.phase === "start" || data.phase === "end" || data.phase === "error")) {
243
244
  const phase = data.phase || "event";
244
245
  const meta = data.meta || data.input || data.args || "";
245
246
  const output = data.output || data.result || data.error || "";
246
- toolCb(`${data.name} ${phase}`.trim(), String(meta || ""), String(output || ""));
247
+ if (toolCb)
248
+ toolCb(`${data.name} ${phase}`.trim(), String(meta || ""), String(output || ""));
249
+ const progressCb = this.progressCallbacks.get(rawKey) || this.progressCallbacks.get(shortKey);
250
+ if (progressCb) {
251
+ const detail = phase === "start" ? String(meta || "") : String(output || meta || "");
252
+ void progressCb({ kind: "tool", phase, name: String(data.name), text: `${data.name} ${phase}${detail ? `: ${detail}` : ""}` });
253
+ }
247
254
  }
248
255
  }
249
256
  if (frame.event === "agent" && frame.payload?.stream === "command_output") {
@@ -348,11 +355,12 @@ export class OpenClawClient {
348
355
  }
349
356
  }
350
357
  flushVerboseAssistantState(rawKey) {
351
- if (!this.isVerboseTranscriptEnabled(rawKey))
352
- return false;
353
358
  const shortKey = rawKey.replace(/^agent:[^:]+:/, "");
354
359
  const cb = this.sessionMessageCallbacks.get(rawKey) || this.sessionMessageCallbacks.get(shortKey);
355
- if (!cb)
360
+ const progressCb = this.progressCallbacks.get(rawKey) || this.progressCallbacks.get(shortKey);
361
+ if (!this.isVerboseTranscriptEnabled(rawKey) && !progressCb)
362
+ return false;
363
+ if (!cb && !progressCb)
356
364
  return false;
357
365
  const key = rawKey;
358
366
  const latest = this.verboseAssistantLatest.get(key)?.trim() || "";
@@ -368,16 +376,22 @@ export class OpenClawClient {
368
376
  if (!toSend)
369
377
  return false;
370
378
  this.verboseAssistantSent.set(key, latest);
371
- cb(toSend, { sourceType: "verbose_transcript" });
379
+ if (this.isVerboseTranscriptEnabled(rawKey) && cb)
380
+ cb(toSend, { sourceType: "verbose_transcript" });
381
+ if (progressCb)
382
+ void progressCb({ kind: "assistant_note", text: toSend });
372
383
  return true;
373
384
  }
374
385
  handleVerboseAssistantStream(payload) {
375
386
  const rawKey = payload?.sessionKey || "";
376
- if (!rawKey || !this.isVerboseTranscriptEnabled(rawKey))
387
+ if (!rawKey)
377
388
  return;
378
389
  const shortKey = rawKey.replace(/^agent:[^:]+:/, "");
379
390
  const cb = this.sessionMessageCallbacks.get(rawKey) || this.sessionMessageCallbacks.get(shortKey);
380
- if (!cb)
391
+ const progressCb = this.progressCallbacks.get(rawKey) || this.progressCallbacks.get(shortKey);
392
+ if (!this.isVerboseTranscriptEnabled(rawKey) && !progressCb)
393
+ return;
394
+ if (!cb && !progressCb)
381
395
  return;
382
396
  const data = payload.data || {};
383
397
  const fullText = typeof data.text === "string" ? data.text : typeof data.deltaText === "string" && data.replace ? data.deltaText : "";
@@ -1090,6 +1104,11 @@ export class OpenClawClient {
1090
1104
  }
1091
1105
  console.log(`[OpenClaw] chat.send runId: ${result.runId} (rpc=${Date.now() - sendStartedAt}ms, attachments=${params.attachments?.length || 0})`);
1092
1106
  await params.onSubmitted?.(result.runId);
1107
+ if (params.onProgress) {
1108
+ this.progressCallbacks.set(sk, params.onProgress);
1109
+ this.progressCallbacks.set(fullSessionKey, params.onProgress);
1110
+ void params.onProgress({ kind: "lifecycle", text: "等待 OpenClaw 回复" });
1111
+ }
1093
1112
  return await this.collectReply(result.runId, params.timeoutMs || 1800000, sk, { emptyFinalAsNoReply: params.emptyFinalAsNoReply, expectedUserText: params.message });
1094
1113
  }
1095
1114
  finally {
@@ -1097,6 +1116,8 @@ export class OpenClawClient {
1097
1116
  // collectReply returns. Keep a short grace window so normal chat replies
1098
1117
  // are not delivered twice via the proactive-message path. Cron/LMA runs
1099
1118
  // are unaffected because they do not go through chatSend.
1119
+ this.progressCallbacks.delete(sk);
1120
+ this.progressCallbacks.delete(fullSessionKey);
1100
1121
  this.releaseSuppressedSessionKeysAfter(suppressedKeys, 30000);
1101
1122
  this.releaseOwnedDeliverySessionKeysAfter(suppressedKeys, 30000);
1102
1123
  }
@@ -1149,6 +1170,7 @@ export class OpenClawClient {
1149
1170
  emptyFinalAsNoReply: params.emptyFinalAsNoReply,
1150
1171
  onSendAttempt: params.onSendAttempt,
1151
1172
  onSubmitted: params.onSubmitted,
1173
+ onProgress: params.onProgress,
1152
1174
  });
1153
1175
  }
1154
1176
  // Build context block + actual message in one chat.send, or write the
@@ -1184,6 +1206,7 @@ export class OpenClawClient {
1184
1206
  emptyFinalAsNoReply: params.emptyFinalAsNoReply,
1185
1207
  onSendAttempt: params.onSendAttempt,
1186
1208
  onSubmitted: params.onSubmitted,
1209
+ onProgress: params.onProgress,
1187
1210
  });
1188
1211
  }
1189
1212
  formatContextLines(messages) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-lark-multi-agent",
3
- "version": "1.0.20",
3
+ "version": "1.0.22",
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": {