openclaw-lark-multi-agent 1.0.4 → 1.0.5

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.
@@ -133,10 +133,11 @@ export class DiscussionManager {
133
133
  buildPrompt(session) {
134
134
  const previous = session.completedRounds.length === 0
135
135
  ? "(暂无,当前是第一轮)"
136
- : session.completedRounds.map((round) => {
136
+ : (() => {
137
+ const round = session.completedRounds[session.completedRounds.length - 1];
137
138
  const lines = Object.entries(round.replies).map(([bot, text]) => `- ${bot}: ${text || "NO_REPLY"}`);
138
139
  return `Round ${round.round}:\n${lines.join("\n")}`;
139
- }).join("\n\n");
140
+ })();
140
141
  return [
141
142
  "这是一个多智能体结构化讨论。",
142
143
  "",
@@ -146,6 +146,7 @@ export declare class FeishuBot {
146
146
  /**
147
147
  * Send a model-drift notification to the affected chat.
148
148
  */
149
+ private hydrateInlineImageKeys;
149
150
  /**
150
151
  * Download a resource (image/file/audio) from a Feishu message.
151
152
  * Returns the local file path.
@@ -360,7 +360,7 @@ export class FeishuBot {
360
360
  }
361
361
  else if (messageType === "post") {
362
362
  // Rich text post - extract all text content
363
- cleanText = this.extractPostText(content);
363
+ cleanText = await this.hydrateInlineImageKeys(this.extractPostText(content), messageId);
364
364
  }
365
365
  else if (messageType === "sticker") {
366
366
  cleanText = `[Sticker: ${content.file_key || "unknown"}]`;
@@ -1107,6 +1107,7 @@ export class FeishuBot {
1107
1107
  currentSenderName: "Discussion Scheduler",
1108
1108
  deliver: false,
1109
1109
  timeoutMs: 1_800_000,
1110
+ emptyFinalAsNoReply: true,
1110
1111
  });
1111
1112
  }
1112
1113
  finally {
@@ -1588,6 +1589,24 @@ export class FeishuBot {
1588
1589
  /**
1589
1590
  * Send a model-drift notification to the affected chat.
1590
1591
  */
1592
+ async hydrateInlineImageKeys(text, messageId) {
1593
+ const imageKeyPattern = /\[Image: (img_[^\]\n]+)\]/g;
1594
+ const replacements = [];
1595
+ for (const match of text.matchAll(imageKeyPattern)) {
1596
+ const imageKey = match[1];
1597
+ try {
1598
+ const imgPath = await this.downloadResource(messageId, imageKey, "image");
1599
+ replacements.push({ from: match[0], to: `[Image: ${imgPath}]` });
1600
+ }
1601
+ catch (err) {
1602
+ replacements.push({ from: match[0], to: `[Image: download failed - ${err.message}]` });
1603
+ }
1604
+ }
1605
+ let out = text;
1606
+ for (const r of replacements)
1607
+ out = out.replace(r.from, r.to);
1608
+ return out;
1609
+ }
1591
1610
  /**
1592
1611
  * Download a resource (image/file/audio) from a Feishu message.
1593
1612
  * Returns the local file path.
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@ import { loadConfig } from "./config.js";
2
2
  import { OpenClawClient } from "./openclaw-client.js";
3
3
  import { MessageStore } from "./message-store.js";
4
4
  import { FeishuBot } from "./feishu-bot.js";
5
- import { mkdirSync } from "fs";
5
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
6
6
  import { dirname, resolve } from "path";
7
7
  import { fileURLToPath } from "url";
8
8
  export async function startApp(configPath) {
@@ -19,6 +19,22 @@ export async function startApp(configPath) {
19
19
  : resolve(dirname(resolvedConfigPath), "data");
20
20
  mkdirSync(dataDir, { recursive: true });
21
21
  console.log(`Data dir: ${dataDir}`);
22
+ const lockPath = resolve(dataDir, "lma.pid");
23
+ if (existsSync(lockPath)) {
24
+ const oldPid = Number(readFileSync(lockPath, "utf8").trim());
25
+ if (Number.isFinite(oldPid) && oldPid > 0) {
26
+ try {
27
+ process.kill(oldPid, 0);
28
+ throw new Error(`Another openclaw-lark-multi-agent instance is already running (pid ${oldPid}). Stop it before starting a new one.`);
29
+ }
30
+ catch (err) {
31
+ const code = err.code;
32
+ if (code !== "ESRCH")
33
+ throw err;
34
+ }
35
+ }
36
+ }
37
+ writeFileSync(lockPath, `${process.pid}\n`);
22
38
  const store = new MessageStore(resolve(dataDir, "messages.db"));
23
39
  // Connect to OpenClaw Gateway via WebSocket
24
40
  const openclawClient = new OpenClawClient(config.openclaw);
@@ -39,6 +55,11 @@ export async function startApp(configPath) {
39
55
  console.log("\nShutting down...");
40
56
  openclawClient.disconnect();
41
57
  store.close();
58
+ try {
59
+ if (readFileSync(lockPath, "utf8").trim() === String(process.pid))
60
+ rmSync(lockPath, { force: true });
61
+ }
62
+ catch { }
42
63
  process.exit(0);
43
64
  };
44
65
  process.on("SIGINT", shutdown);
@@ -6,6 +6,8 @@ export type ChatAttachment = {
6
6
  fileName?: string;
7
7
  content: string;
8
8
  };
9
+ export declare const GATEWAY_PROTOCOL_MIN = 3;
10
+ export declare const GATEWAY_PROTOCOL_MAX = 4;
9
11
  /**
10
12
  * OpenClaw Gateway WebSocket client.
11
13
  * Full agent pipeline — tools, memory, skills, context management by OpenClaw.
@@ -25,9 +27,12 @@ export declare class OpenClawClient {
25
27
  private sessionMessageCallbacks;
26
28
  /** Session keys that should be re-subscribed on reconnect */
27
29
  private subscribedKeys;
28
- /** Session keys with active/recent chatSend proactive is forwarded and deduped by outbox. */
30
+ /** Session keys whose transcript/session.message updates are currently suppressed. */
29
31
  private suppressedSessions;
30
32
  private suppressedSessionTimers;
33
+ /** Session keys whose delivery is owned by this bridge's chatSend final path. */
34
+ private ownedDeliverySessions;
35
+ private ownedDeliverySessionTimers;
31
36
  /** Session keys whose proactive messages must be dropped by the bridge (e.g. discussion scheduler owns delivery). */
32
37
  private mutedProactiveSessions;
33
38
  private mutedProactiveSessionCounts;
@@ -73,8 +78,15 @@ export declare class OpenClawClient {
73
78
  * deliver=false prevents OpenClaw from auto-posting to channels.
74
79
  */
75
80
  abortChat(sessionKey: string, runId: string): Promise<any>;
81
+ private sessionKeyVariants;
82
+ private trackChatEventSession;
83
+ private extractTextFromChatMessage;
84
+ private emitProactiveForSession;
76
85
  private suppressSessionKeys;
77
86
  private releaseSuppressedSessionKeysAfter;
87
+ private ownDeliverySessionKeys;
88
+ private releaseOwnedDeliverySessionKeysAfter;
89
+ private isOwnedDeliverySession;
78
90
  private addMutedProactiveKey;
79
91
  private releaseMutedProactiveKey;
80
92
  muteProactiveDelivery(sessionKey: string): (delayMs?: number) => void;
@@ -84,6 +96,7 @@ export declare class OpenClawClient {
84
96
  attachments?: ChatAttachment[];
85
97
  deliver?: boolean;
86
98
  timeoutMs?: number;
99
+ emptyFinalAsNoReply?: boolean;
87
100
  }): Promise<string>;
88
101
  private shouldInjectBridgeAttachmentHint;
89
102
  private bridgeAttachmentHint;
@@ -107,6 +120,7 @@ export declare class OpenClawClient {
107
120
  currentSenderName: string;
108
121
  deliver?: boolean;
109
122
  timeoutMs?: number;
123
+ emptyFinalAsNoReply?: boolean;
110
124
  }): Promise<string>;
111
125
  private extractImageAttachments;
112
126
  disconnect(): Promise<void>;
@@ -3,6 +3,8 @@ import { randomUUID } from "crypto";
3
3
  import { readFileSync } from "fs";
4
4
  import { basename, extname } from "path";
5
5
  import { getBridgeAttachmentsDir } from "./paths.js";
6
+ export const GATEWAY_PROTOCOL_MIN = 3;
7
+ export const GATEWAY_PROTOCOL_MAX = 4;
6
8
  const BRIDGE_ATTACHMENTS_DIR = getBridgeAttachmentsDir();
7
9
  /**
8
10
  * OpenClaw Gateway WebSocket client.
@@ -23,9 +25,12 @@ export class OpenClawClient {
23
25
  sessionMessageCallbacks = new Map();
24
26
  /** Session keys that should be re-subscribed on reconnect */
25
27
  subscribedKeys = new Set();
26
- /** Session keys with active/recent chatSend proactive is forwarded and deduped by outbox. */
28
+ /** Session keys whose transcript/session.message updates are currently suppressed. */
27
29
  suppressedSessions = new Set();
28
30
  suppressedSessionTimers = new Map();
31
+ /** Session keys whose delivery is owned by this bridge's chatSend final path. */
32
+ ownedDeliverySessions = new Set();
33
+ ownedDeliverySessionTimers = new Map();
29
34
  /** Session keys whose proactive messages must be dropped by the bridge (e.g. discussion scheduler owns delivery). */
30
35
  mutedProactiveSessions = new Set();
31
36
  mutedProactiveSessionCounts = new Map();
@@ -60,8 +65,8 @@ export class OpenClawClient {
60
65
  id: "connect-1",
61
66
  method: "connect",
62
67
  params: {
63
- minProtocol: 4,
64
- maxProtocol: 99,
68
+ minProtocol: GATEWAY_PROTOCOL_MIN,
69
+ maxProtocol: GATEWAY_PROTOCOL_MAX,
65
70
  client: {
66
71
  id: "gateway-client",
67
72
  version: "1.0.0",
@@ -111,8 +116,24 @@ export class OpenClawClient {
111
116
  // Normalize chat events to look like agent events for collectReply
112
117
  if (frame.event === "chat") {
113
118
  const state = frame.payload?.state;
119
+ this.trackChatEventSession(sk, state, frame.payload);
114
120
  const msg = frame.payload?.message;
115
- if (state === "final") {
121
+ if (state === "delta") {
122
+ // v4: chat delta events carry deltaText (incremental text chunk)
123
+ const deltaText = frame.payload?.deltaText;
124
+ if (deltaText) {
125
+ this.agentEvents.get(sk).push({
126
+ ...frame.payload,
127
+ stream: "chatDelta",
128
+ data: {
129
+ deltaText,
130
+ delta: deltaText, // v3 compat
131
+ replace: frame.payload?.replace || false,
132
+ },
133
+ });
134
+ }
135
+ }
136
+ else if (state === "final") {
116
137
  // Store chat final text as fallback (only used if agent stream had no text)
117
138
  const textParts = [];
118
139
  if (msg?.content) {
@@ -215,7 +236,8 @@ export class OpenClawClient {
215
236
  return false;
216
237
  }
217
238
  if (this.suppressedSessions.has(rawKey) || this.suppressedSessions.has(shortKey)) {
218
- console.log(`[OpenClaw] Forwarding proactive msg for ${shortKey} during active chatSend; delivery outbox will dedupe`);
239
+ console.log(`[OpenClaw] Dropping proactive transcript msg for ${shortKey}; waiting for final delivery path`);
240
+ return false;
219
241
  }
220
242
  const cb = this.sessionMessageCallbacks.get(rawKey) || this.sessionMessageCallbacks.get(shortKey);
221
243
  if (cb)
@@ -266,9 +288,10 @@ export class OpenClawClient {
266
288
  * No aggressive timeout — waits for lifecycle end as the source of truth.
267
289
  * 30-minute safety net only for catastrophic WS disconnection.
268
290
  */
269
- collectReply(runId, timeoutMs = 1800000, targetSessionKey) {
291
+ collectReply(runId, timeoutMs = 1800000, targetSessionKey, options) {
270
292
  return new Promise((resolve, reject) => {
271
293
  let text = "";
294
+ let chatDeltaText = "";
272
295
  let chatFinalText = "";
273
296
  let sessionKey = targetSessionKey ? `agent:main:${targetSessionKey}` : "";
274
297
  let chatFinalTimer = null;
@@ -292,7 +315,7 @@ export class OpenClawClient {
292
315
  this.abortChat(targetSessionKey || sessionKey, runId).catch((err) => {
293
316
  console.warn(`[OpenClaw] abort after collectReply idle timeout failed:`, err.message);
294
317
  });
295
- resolve(text || chatFinalText || "(timeout: no reply received)");
318
+ resolve(text || chatDeltaText || chatFinalText || "(timeout: no reply received)");
296
319
  }, timeoutMs);
297
320
  };
298
321
  resetIdleTimer();
@@ -348,14 +371,23 @@ export class OpenClawClient {
348
371
  lifecycleStartedLogged = true;
349
372
  console.log(`[OpenClaw] lifecycle start for runId=${runId} after ${Date.now() - collectStartedAt}ms`);
350
373
  }
351
- if (ev.stream === "assistant" && (ev.data?.deltaText || ev.data?.delta)) {
374
+ if ((ev.stream === "assistant" || ev.stream === "chatDelta") && (ev.data?.deltaText || ev.data?.delta)) {
352
375
  const chunk = ev.data.deltaText || ev.data.delta;
353
- if (ev.data?.replace) {
354
- // v4: non-prefix replacement — deltaText is the full replacement
355
- text = chunk;
376
+ if (ev.stream === "assistant") {
377
+ if (ev.data?.replace) {
378
+ text = chunk;
379
+ }
380
+ else {
381
+ text += chunk;
382
+ }
356
383
  }
357
384
  else {
358
- text += chunk;
385
+ if (ev.data?.replace) {
386
+ chatDeltaText = chunk;
387
+ }
388
+ else {
389
+ chatDeltaText += chunk;
390
+ }
359
391
  }
360
392
  }
361
393
  if (ev.stream === "chatFinal") {
@@ -369,10 +401,13 @@ export class OpenClawClient {
369
401
  });
370
402
  // Prefer final chat message over accumulated deltas: some providers may
371
403
  // emit only partial deltas (e.g. "N") while final contains "NO_REPLY".
372
- const latestFinalText = chatFinalText || text;
404
+ const latestFinalText = chatFinalText || text || chatDeltaText;
373
405
  if (latestFinalText) {
374
406
  finish(latestFinalText);
375
407
  }
408
+ else if (options?.emptyFinalAsNoReply) {
409
+ finish("NO_REPLY");
410
+ }
376
411
  else {
377
412
  console.warn(`[OpenClaw] collectReply: empty chatFinal fallback ignored; waiting for real text or idle timeout`);
378
413
  chatFinalTimer = null;
@@ -383,9 +418,9 @@ export class OpenClawClient {
383
418
  if (ev.stream === "lifecycle" && ev.data?.phase === "end") {
384
419
  // Prefer final chat message over accumulated deltas: some providers may
385
420
  // emit only partial deltas (e.g. "N") while final contains "NO_REPLY".
386
- const finalText = chatFinalText || text;
421
+ const finalText = chatFinalText || text || chatDeltaText;
387
422
  const finishFromLifecycle = () => {
388
- const latestFinalText = chatFinalText || text;
423
+ const latestFinalText = chatFinalText || text || chatDeltaText;
389
424
  if (!chatFinalText && latestFinalText.trim() === "N") {
390
425
  // Some providers stream the first character of NO_REPLY ("N") but
391
426
  // never deliver a final chat message in time. Never surface a lone
@@ -393,6 +428,10 @@ export class OpenClawClient {
393
428
  finish("NO_REPLY");
394
429
  return;
395
430
  }
431
+ if (!latestFinalText && options?.emptyFinalAsNoReply) {
432
+ finish("NO_REPLY");
433
+ return;
434
+ }
396
435
  if (!latestFinalText) {
397
436
  const state = ev.data?.livenessState || "unknown";
398
437
  const reason = ev.data?.stopReason || "";
@@ -414,7 +453,7 @@ export class OpenClawClient {
414
453
  };
415
454
  // If lifecycle end beats chat final, a short delta like "N" can be a truncated
416
455
  // final reply. Wait for chatFinal before resolving; otherwise suppress lone "N".
417
- if (!chatFinalText && text.length <= 1) {
456
+ if (!options?.emptyFinalAsNoReply && !chatFinalText && text.length <= 1) {
418
457
  lifecycleEndTimer = setTimeout(finishFromLifecycle, 5000);
419
458
  }
420
459
  else {
@@ -490,6 +529,53 @@ export class OpenClawClient {
490
529
  const key = sessionKey.startsWith("agent:main:") ? sessionKey.slice("agent:main:".length) : sessionKey;
491
530
  return this.rpc("chat.abort", { sessionKey: key, runId }, 5000).catch(() => { });
492
531
  }
532
+ sessionKeyVariants(key) {
533
+ const shortKey = key.startsWith("agent:main:") ? key.slice("agent:main:".length) : key;
534
+ return [shortKey, `agent:main:${shortKey}`];
535
+ }
536
+ trackChatEventSession(sessionKey, state, payload) {
537
+ if (!sessionKey || sessionKey === "__default__")
538
+ return;
539
+ const keys = this.sessionKeyVariants(sessionKey);
540
+ if (this.isOwnedDeliverySession(sessionKey)) {
541
+ // This chat event belongs to a bridge-owned chat.send run. The final answer
542
+ // is delivered by collectReply/processQueue, so transcript session.message
543
+ // mirrors must stay suppressed briefly.
544
+ this.suppressSessionKeys(keys);
545
+ if (state === "final" || state === "error" || state === "aborted")
546
+ this.releaseSuppressedSessionKeysAfter(keys, 30000);
547
+ return;
548
+ }
549
+ // External WebChat/Control UI chat against an LMA session: do not forward
550
+ // streaming transcript updates, but allow the final chat message to be
551
+ // delivered through the proactive callback after it is committed.
552
+ if (state === "delta") {
553
+ this.suppressSessionKeys(keys);
554
+ }
555
+ else if (state === "final") {
556
+ const text = this.extractTextFromChatMessage(payload?.message);
557
+ if (text)
558
+ this.emitProactiveForSession(sessionKey, text);
559
+ // Keep transcript mirrors suppressed briefly; chat final already emitted
560
+ // the user-visible result for external WebChat/Control UI turns.
561
+ this.releaseSuppressedSessionKeysAfter(keys, 30000);
562
+ }
563
+ else if (state === "error" || state === "aborted") {
564
+ this.releaseSuppressedSessionKeysAfter(keys, 0);
565
+ }
566
+ }
567
+ extractTextFromChatMessage(message) {
568
+ const parts = Array.isArray(message?.content) ? message.content : [];
569
+ return parts.filter((part) => part?.type === "text" && typeof part.text === "string").map((part) => part.text).join("\n").trim();
570
+ }
571
+ emitProactiveForSession(sessionKey, text) {
572
+ const [shortKey, fullKey] = this.sessionKeyVariants(sessionKey);
573
+ const cb = this.sessionMessageCallbacks.get(fullKey) || this.sessionMessageCallbacks.get(shortKey);
574
+ if (!cb)
575
+ return false;
576
+ cb(text);
577
+ return true;
578
+ }
493
579
  suppressSessionKeys(keys) {
494
580
  for (const key of keys) {
495
581
  const timer = this.suppressedSessionTimers.get(key);
@@ -511,6 +597,31 @@ export class OpenClawClient {
511
597
  this.suppressedSessionTimers.set(key, timer);
512
598
  }
513
599
  }
600
+ ownDeliverySessionKeys(keys) {
601
+ for (const key of keys) {
602
+ const timer = this.ownedDeliverySessionTimers.get(key);
603
+ if (timer)
604
+ clearTimeout(timer);
605
+ this.ownedDeliverySessionTimers.delete(key);
606
+ this.ownedDeliverySessions.add(key);
607
+ }
608
+ }
609
+ releaseOwnedDeliverySessionKeysAfter(keys, delayMs) {
610
+ for (const key of keys) {
611
+ const oldTimer = this.ownedDeliverySessionTimers.get(key);
612
+ if (oldTimer)
613
+ clearTimeout(oldTimer);
614
+ const timer = setTimeout(() => {
615
+ this.ownedDeliverySessions.delete(key);
616
+ this.ownedDeliverySessionTimers.delete(key);
617
+ }, delayMs);
618
+ this.ownedDeliverySessionTimers.set(key, timer);
619
+ }
620
+ }
621
+ isOwnedDeliverySession(sessionKey) {
622
+ const [shortKey, fullKey] = this.sessionKeyVariants(sessionKey);
623
+ return this.ownedDeliverySessions.has(shortKey) || this.ownedDeliverySessions.has(fullKey);
624
+ }
514
625
  addMutedProactiveKey(key) {
515
626
  const count = this.mutedProactiveSessionCounts.get(key) || 0;
516
627
  this.mutedProactiveSessionCounts.set(key, count + 1);
@@ -552,6 +663,7 @@ export class OpenClawClient {
552
663
  const fullSessionKey = `agent:main:${sk}`;
553
664
  const suppressedKeys = [sk, fullSessionKey];
554
665
  this.suppressSessionKeys(suppressedKeys);
666
+ this.ownDeliverySessionKeys(suppressedKeys);
555
667
  try {
556
668
  // Drop stale buffered events for this session before starting a new run.
557
669
  // This prevents an old final text (e.g. previous "ok") from being consumed by
@@ -567,7 +679,7 @@ export class OpenClawClient {
567
679
  idempotencyKey: randomUUID(),
568
680
  });
569
681
  console.log(`[OpenClaw] chat.send runId: ${result.runId} (rpc=${Date.now() - sendStartedAt}ms, attachments=${params.attachments?.length || 0})`);
570
- return await this.collectReply(result.runId, params.timeoutMs || 1800000, sk);
682
+ return await this.collectReply(result.runId, params.timeoutMs || 1800000, sk, { emptyFinalAsNoReply: params.emptyFinalAsNoReply });
571
683
  }
572
684
  finally {
573
685
  // OpenClaw can emit the final assistant session.message a moment after
@@ -575,6 +687,7 @@ export class OpenClawClient {
575
687
  // are not delivered twice via the proactive-message path. Cron/LMA runs
576
688
  // are unaffected because they do not go through chatSend.
577
689
  this.releaseSuppressedSessionKeysAfter(suppressedKeys, 30000);
690
+ this.releaseOwnedDeliverySessionKeysAfter(suppressedKeys, 30000);
578
691
  }
579
692
  }
580
693
  shouldInjectBridgeAttachmentHint(text) {
@@ -617,6 +730,7 @@ export class OpenClawClient {
617
730
  attachments,
618
731
  deliver: params.deliver,
619
732
  timeoutMs: params.timeoutMs,
733
+ emptyFinalAsNoReply: params.emptyFinalAsNoReply,
620
734
  });
621
735
  }
622
736
  // Build context block + actual message in one chat.send
@@ -639,6 +753,7 @@ export class OpenClawClient {
639
753
  attachments,
640
754
  deliver: params.deliver,
641
755
  timeoutMs: params.timeoutMs,
756
+ emptyFinalAsNoReply: params.emptyFinalAsNoReply,
642
757
  });
643
758
  }
644
759
  extractImageAttachments(contents) {
@@ -676,6 +791,10 @@ export class OpenClawClient {
676
791
  this.suppressedSessions.clear();
677
792
  this.mutedProactiveSessions.clear();
678
793
  this.mutedProactiveSessionCounts.clear();
794
+ for (const timer of this.ownedDeliverySessionTimers.values())
795
+ clearTimeout(timer);
796
+ this.ownedDeliverySessionTimers.clear();
797
+ this.ownedDeliverySessions.clear();
679
798
  if (this.ws) {
680
799
  this.ws.close();
681
800
  this.ws = null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-lark-multi-agent",
3
- "version": "1.0.4",
3
+ "version": "1.0.5",
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": {