@alfe.ai/openclaw-chat 0.9.4 → 0.9.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.
package/dist/index.d.cts CHANGED
@@ -1,2 +1,2 @@
1
- import { C as ChatMessage, S as AlfeResolvedAccount, T as SessionSummary, b as AlfeChannelConfig, h as plugin, w as SessionData, x as AlfePluginConfig, y as createAlfeChannelPlugin } from "./plugin.cjs";
1
+ import { C as AlfeResolvedAccount, E as SessionSummary, S as AlfePluginConfig, T as SessionData, b as createAlfeChannelPlugin, g as plugin, w as ChatMessage, x as AlfeChannelConfig } from "./plugin.cjs";
2
2
  export { type AlfeChannelConfig, type AlfePluginConfig, type AlfeResolvedAccount, type ChatMessage, type SessionData, type SessionSummary, createAlfeChannelPlugin, plugin as default };
package/dist/index.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- import { C as ChatMessage, S as AlfeResolvedAccount, T as SessionSummary, b as AlfeChannelConfig, h as plugin, w as SessionData, x as AlfePluginConfig, y as createAlfeChannelPlugin } from "./plugin.js";
1
+ import { C as AlfeResolvedAccount, E as SessionSummary, S as AlfePluginConfig, T as SessionData, b as createAlfeChannelPlugin, g as plugin, w as ChatMessage, x as AlfeChannelConfig } from "./plugin.js";
2
2
  export { type AlfeChannelConfig, type AlfePluginConfig, type AlfeResolvedAccount, type ChatMessage, type SessionData, type SessionSummary, createAlfeChannelPlugin, plugin as default };
package/dist/index.js CHANGED
@@ -1,2 +1,2 @@
1
- import { f as plugin, g as createAlfeChannelPlugin } from "./plugin2.js";
1
+ import { _ as createAlfeChannelPlugin, p as plugin } from "./plugin2.js";
2
2
  export { createAlfeChannelPlugin, plugin as default };
package/dist/plugin.cjs CHANGED
@@ -13,6 +13,7 @@ exports.buildA2ACompletePayload = require_plugin.buildA2ACompletePayload;
13
13
  exports.buildOriginEnvelopeContext = require_plugin.buildOriginEnvelopeContext;
14
14
  exports.buildToolActivity = require_plugin.buildToolActivity;
15
15
  exports.computeOpenClawSdkAnchors = require_plugin.computeOpenClawSdkAnchors;
16
+ exports.decideChatServiceStart = require_plugin.decideChatServiceStart;
16
17
  exports.default = require_plugin.plugin;
17
18
  exports.joinAssistantText = require_plugin.joinAssistantText;
18
19
  exports.resolveAbortTargetKeys = require_plugin.resolveAbortTargetKeys;
package/dist/plugin.d.cts CHANGED
@@ -64,6 +64,20 @@ type StoredMessageComponent = {
64
64
  label: string;
65
65
  value: string;
66
66
  };
67
+ /**
68
+ * Persisted reference to a message attachment. Deliberately stores NO URL —
69
+ * the download URL is short-lived and re-signed on demand through the chat
70
+ * service's participant-gated resolve endpoint (keyed by `attachmentId`). Only
71
+ * media bound to the conversation (a real chat-bucket upload) is persisted;
72
+ * remote-passthrough media has no `attachmentId` and is not stored.
73
+ */
74
+ interface StoredAttachment {
75
+ attachmentId: string;
76
+ type: 'image' | 'video' | 'audio' | 'document' | 'file';
77
+ mimeType: string;
78
+ filename: string;
79
+ size?: number;
80
+ }
67
81
  interface ChatMessage {
68
82
  role: 'user' | 'assistant';
69
83
  content: string;
@@ -77,6 +91,12 @@ interface ChatMessage {
77
91
  * content). Persisted so a refresh / reconnect replays the button.
78
92
  */
79
93
  components?: StoredMessageComponent[];
94
+ /**
95
+ * Attachment references (images/files). Additive — absent on pre-attachment
96
+ * files. Persisted so history replays them; the client resolves each URL on
97
+ * demand via the participant-gated endpoint. See {@link StoredAttachment}.
98
+ */
99
+ attachments?: StoredAttachment[];
80
100
  }
81
101
  /**
82
102
  * Persisted form of a turn's agent activity (tool cards + thinking), so a
@@ -600,6 +620,32 @@ interface AnchorEnv {
600
620
  * Pure except for the injected `realpath` / `which` seams — unit-tested.
601
621
  */
602
622
  declare function computeOpenClawSdkAnchors(deps: AnchorEnv): string[];
623
+ /**
624
+ * Decide what `startChatService()` should do given the current connection
625
+ * state. Pure so the branch logic — the load-bearing fix for the
626
+ * health-monitor no-op restart loop (Badi Junior, 2026-07-09) — is unit
627
+ * testable without standing up a real WebSocket or the OpenClaw SDK.
628
+ *
629
+ * OpenClaw calls the registered service's `start()` in two very different
630
+ * situations that a sticky activation flag could not tell apart:
631
+ * - a redundant duplicate (re-`activate()` per tool-discovery pass / reconnect)
632
+ * → must be a no-op ('skip'), and
633
+ * - a channel health-monitor REVIVE of a channel it decided is "stopped"
634
+ * (`health-monitor: [alfe:default] restarting (reason: stopped)`), sometimes
635
+ * with no preceding `stop()` → must actually reconnect.
636
+ *
637
+ * Keying on the live client fixes it:
638
+ * - a connect in flight, or an already-connected client → 'skip'
639
+ * - a client that exists but is not connected → 'recycle' it then reconnect
640
+ * - no client at all → 'connect'
641
+ *
642
+ * Exported for unit tests.
643
+ */
644
+ declare function decideChatServiceStart(state: {
645
+ connectInFlight: boolean;
646
+ clientPresent: boolean;
647
+ clientConnected: boolean;
648
+ }): 'skip' | 'recycle' | 'connect';
603
649
  declare function enqueueAgentTurn(task: () => Promise<void>, opts?: {
604
650
  coalesceKey?: string;
605
651
  onSuperseded?: () => void;
@@ -681,4 +727,4 @@ declare const plugin: {
681
727
  deactivate(api: PluginApi): Promise<void>;
682
728
  };
683
729
  //#endregion
684
- export { ChatMessage as C, AlfeResolvedAccount as S, SessionSummary as T, rewriteAssistantText as _, VOICE_REPLY_SYSTEM_PROMPT as a, AlfeChannelConfig as b, __setActiveRunForTest as c, buildOriginEnvelopeContext as d, buildToolActivity as f, resolveAbortTargetKeys as g, plugin as h, RunEventGate as i, admitAgentEvent as l, joinAssistantText as m, AnchorEnv as n, __agentTurnQueueForTest as o, computeOpenClawSdkAnchors as p, CROSS_AGENT_TOOLS_SYSTEM_PROMPT as r, __resetAgentTurnQueueForTest as s, ActiveRunRef as t, buildA2ACompletePayload as u, stripLeakedToolSummary as v, SessionData as w, AlfePluginConfig as x, createAlfeChannelPlugin as y };
730
+ export { AlfeResolvedAccount as C, SessionSummary as E, AlfePluginConfig as S, SessionData as T, resolveAbortTargetKeys as _, VOICE_REPLY_SYSTEM_PROMPT as a, createAlfeChannelPlugin as b, __setActiveRunForTest as c, buildOriginEnvelopeContext as d, buildToolActivity as f, plugin as g, joinAssistantText as h, RunEventGate as i, admitAgentEvent as l, decideChatServiceStart as m, AnchorEnv as n, __agentTurnQueueForTest as o, computeOpenClawSdkAnchors as p, CROSS_AGENT_TOOLS_SYSTEM_PROMPT as r, __resetAgentTurnQueueForTest as s, ActiveRunRef as t, buildA2ACompletePayload as u, rewriteAssistantText as v, ChatMessage as w, AlfeChannelConfig as x, stripLeakedToolSummary as y };
package/dist/plugin.d.ts CHANGED
@@ -64,6 +64,20 @@ type StoredMessageComponent = {
64
64
  label: string;
65
65
  value: string;
66
66
  };
67
+ /**
68
+ * Persisted reference to a message attachment. Deliberately stores NO URL —
69
+ * the download URL is short-lived and re-signed on demand through the chat
70
+ * service's participant-gated resolve endpoint (keyed by `attachmentId`). Only
71
+ * media bound to the conversation (a real chat-bucket upload) is persisted;
72
+ * remote-passthrough media has no `attachmentId` and is not stored.
73
+ */
74
+ interface StoredAttachment {
75
+ attachmentId: string;
76
+ type: 'image' | 'video' | 'audio' | 'document' | 'file';
77
+ mimeType: string;
78
+ filename: string;
79
+ size?: number;
80
+ }
67
81
  interface ChatMessage {
68
82
  role: 'user' | 'assistant';
69
83
  content: string;
@@ -77,6 +91,12 @@ interface ChatMessage {
77
91
  * content). Persisted so a refresh / reconnect replays the button.
78
92
  */
79
93
  components?: StoredMessageComponent[];
94
+ /**
95
+ * Attachment references (images/files). Additive — absent on pre-attachment
96
+ * files. Persisted so history replays them; the client resolves each URL on
97
+ * demand via the participant-gated endpoint. See {@link StoredAttachment}.
98
+ */
99
+ attachments?: StoredAttachment[];
80
100
  }
81
101
  /**
82
102
  * Persisted form of a turn's agent activity (tool cards + thinking), so a
@@ -600,6 +620,32 @@ interface AnchorEnv {
600
620
  * Pure except for the injected `realpath` / `which` seams — unit-tested.
601
621
  */
602
622
  declare function computeOpenClawSdkAnchors(deps: AnchorEnv): string[];
623
+ /**
624
+ * Decide what `startChatService()` should do given the current connection
625
+ * state. Pure so the branch logic — the load-bearing fix for the
626
+ * health-monitor no-op restart loop (Badi Junior, 2026-07-09) — is unit
627
+ * testable without standing up a real WebSocket or the OpenClaw SDK.
628
+ *
629
+ * OpenClaw calls the registered service's `start()` in two very different
630
+ * situations that a sticky activation flag could not tell apart:
631
+ * - a redundant duplicate (re-`activate()` per tool-discovery pass / reconnect)
632
+ * → must be a no-op ('skip'), and
633
+ * - a channel health-monitor REVIVE of a channel it decided is "stopped"
634
+ * (`health-monitor: [alfe:default] restarting (reason: stopped)`), sometimes
635
+ * with no preceding `stop()` → must actually reconnect.
636
+ *
637
+ * Keying on the live client fixes it:
638
+ * - a connect in flight, or an already-connected client → 'skip'
639
+ * - a client that exists but is not connected → 'recycle' it then reconnect
640
+ * - no client at all → 'connect'
641
+ *
642
+ * Exported for unit tests.
643
+ */
644
+ declare function decideChatServiceStart(state: {
645
+ connectInFlight: boolean;
646
+ clientPresent: boolean;
647
+ clientConnected: boolean;
648
+ }): 'skip' | 'recycle' | 'connect';
603
649
  declare function enqueueAgentTurn(task: () => Promise<void>, opts?: {
604
650
  coalesceKey?: string;
605
651
  onSuperseded?: () => void;
@@ -681,4 +727,4 @@ declare const plugin: {
681
727
  deactivate(api: PluginApi): Promise<void>;
682
728
  };
683
729
  //#endregion
684
- export { ChatMessage as C, AlfeResolvedAccount as S, SessionSummary as T, rewriteAssistantText as _, VOICE_REPLY_SYSTEM_PROMPT as a, AlfeChannelConfig as b, __setActiveRunForTest as c, buildOriginEnvelopeContext as d, buildToolActivity as f, resolveAbortTargetKeys as g, plugin as h, RunEventGate as i, admitAgentEvent as l, joinAssistantText as m, AnchorEnv as n, __agentTurnQueueForTest as o, computeOpenClawSdkAnchors as p, CROSS_AGENT_TOOLS_SYSTEM_PROMPT as r, __resetAgentTurnQueueForTest as s, ActiveRunRef as t, buildA2ACompletePayload as u, stripLeakedToolSummary as v, SessionData as w, AlfePluginConfig as x, createAlfeChannelPlugin as y };
730
+ export { AlfeResolvedAccount as C, SessionSummary as E, AlfePluginConfig as S, SessionData as T, resolveAbortTargetKeys as _, VOICE_REPLY_SYSTEM_PROMPT as a, createAlfeChannelPlugin as b, __setActiveRunForTest as c, buildOriginEnvelopeContext as d, buildToolActivity as f, plugin as g, joinAssistantText as h, RunEventGate as i, admitAgentEvent as l, decideChatServiceStart as m, AnchorEnv as n, __agentTurnQueueForTest as o, computeOpenClawSdkAnchors as p, CROSS_AGENT_TOOLS_SYSTEM_PROMPT as r, __resetAgentTurnQueueForTest as s, ActiveRunRef as t, buildA2ACompletePayload as u, rewriteAssistantText as v, ChatMessage as w, AlfeChannelConfig as x, stripLeakedToolSummary as y };
package/dist/plugin.js CHANGED
@@ -1,2 +1,2 @@
1
- import { a as __setActiveRunForTest, c as buildOriginEnvelopeContext, d as joinAssistantText, f as plugin, h as stripLeakedToolSummary, i as __resetAgentTurnQueueForTest, l as buildToolActivity, m as rewriteAssistantText, n as VOICE_REPLY_SYSTEM_PROMPT, o as admitAgentEvent, p as resolveAbortTargetKeys, r as __agentTurnQueueForTest, s as buildA2ACompletePayload, t as CROSS_AGENT_TOOLS_SYSTEM_PROMPT, u as computeOpenClawSdkAnchors } from "./plugin2.js";
2
- export { CROSS_AGENT_TOOLS_SYSTEM_PROMPT, VOICE_REPLY_SYSTEM_PROMPT, __agentTurnQueueForTest, __resetAgentTurnQueueForTest, __setActiveRunForTest, admitAgentEvent, buildA2ACompletePayload, buildOriginEnvelopeContext, buildToolActivity, computeOpenClawSdkAnchors, plugin as default, joinAssistantText, resolveAbortTargetKeys, rewriteAssistantText, stripLeakedToolSummary };
1
+ import { a as __setActiveRunForTest, c as buildOriginEnvelopeContext, d as decideChatServiceStart, f as joinAssistantText, g as stripLeakedToolSummary, h as rewriteAssistantText, i as __resetAgentTurnQueueForTest, l as buildToolActivity, m as resolveAbortTargetKeys, n as VOICE_REPLY_SYSTEM_PROMPT, o as admitAgentEvent, p as plugin, r as __agentTurnQueueForTest, s as buildA2ACompletePayload, t as CROSS_AGENT_TOOLS_SYSTEM_PROMPT, u as computeOpenClawSdkAnchors } from "./plugin2.js";
2
+ export { CROSS_AGENT_TOOLS_SYSTEM_PROMPT, VOICE_REPLY_SYSTEM_PROMPT, __agentTurnQueueForTest, __resetAgentTurnQueueForTest, __setActiveRunForTest, admitAgentEvent, buildA2ACompletePayload, buildOriginEnvelopeContext, buildToolActivity, computeOpenClawSdkAnchors, decideChatServiceStart, plugin as default, joinAssistantText, resolveAbortTargetKeys, rewriteAssistantText, stripLeakedToolSummary };
package/dist/plugin2.cjs CHANGED
@@ -210,7 +210,8 @@ async function uploadLocalFile(localPath, log, deps) {
210
210
  url: presigned.downloadUrl,
211
211
  filename,
212
212
  mimeType,
213
- size
213
+ size,
214
+ attachmentId: presigned.id
214
215
  };
215
216
  } catch (err) {
216
217
  log.warn(`Outbound media upload failed for ${filename}: ${err instanceof Error ? err.message : String(err)}`);
@@ -874,7 +875,7 @@ async function createSession(sessionId, agentId, channel, tenantId, userId) {
874
875
  return session;
875
876
  }
876
877
  const writeLocks = /* @__PURE__ */ new Map();
877
- async function addMessage(sessionId, role, content, senderId, senderName, components) {
878
+ async function addMessage(sessionId, role, content, senderId, senderName, components, attachments) {
878
879
  const next = (writeLocks.get(sessionId) ?? Promise.resolve()).then(async () => {
879
880
  const session = await getSession(sessionId);
880
881
  if (!session) return;
@@ -884,7 +885,8 @@ async function addMessage(sessionId, role, content, senderId, senderName, compon
884
885
  timestamp: Date.now(),
885
886
  ...senderId ? { senderId } : {},
886
887
  ...senderName ? { senderName } : {},
887
- ...components?.length ? { components } : {}
888
+ ...components?.length ? { components } : {},
889
+ ...attachments?.length ? { attachments } : {}
888
890
  });
889
891
  await saveSession(session);
890
892
  }).finally(() => {
@@ -2201,6 +2203,105 @@ function resolveOpenClawSdk(log) {
2201
2203
  let pluginRuntime = null;
2202
2204
  let chatClient = null;
2203
2205
  let connectingPromise = null;
2206
+ /**
2207
+ * Decide what `startChatService()` should do given the current connection
2208
+ * state. Pure so the branch logic — the load-bearing fix for the
2209
+ * health-monitor no-op restart loop (Badi Junior, 2026-07-09) — is unit
2210
+ * testable without standing up a real WebSocket or the OpenClaw SDK.
2211
+ *
2212
+ * OpenClaw calls the registered service's `start()` in two very different
2213
+ * situations that a sticky activation flag could not tell apart:
2214
+ * - a redundant duplicate (re-`activate()` per tool-discovery pass / reconnect)
2215
+ * → must be a no-op ('skip'), and
2216
+ * - a channel health-monitor REVIVE of a channel it decided is "stopped"
2217
+ * (`health-monitor: [alfe:default] restarting (reason: stopped)`), sometimes
2218
+ * with no preceding `stop()` → must actually reconnect.
2219
+ *
2220
+ * Keying on the live client fixes it:
2221
+ * - a connect in flight, or an already-connected client → 'skip'
2222
+ * - a client that exists but is not connected → 'recycle' it then reconnect
2223
+ * - no client at all → 'connect'
2224
+ *
2225
+ * Exported for unit tests.
2226
+ */
2227
+ function decideChatServiceStart(state) {
2228
+ if (state.connectInFlight) return "skip";
2229
+ if (state.clientPresent && state.clientConnected) return "skip";
2230
+ if (state.clientPresent) return "recycle";
2231
+ return "connect";
2232
+ }
2233
+ const STORED_ATTACHMENT_TYPES = new Set([
2234
+ "image",
2235
+ "video",
2236
+ "audio",
2237
+ "document",
2238
+ "file"
2239
+ ]);
2240
+ /** Narrow a possibly-loose type string, inferring from the mime as a fallback. */
2241
+ function coerceStoredAttachmentType(type, mimeType) {
2242
+ if (type && STORED_ATTACHMENT_TYPES.has(type)) return type;
2243
+ const m = mimeType ?? "";
2244
+ if (m.startsWith("image/")) return "image";
2245
+ if (m.startsWith("video/")) return "video";
2246
+ if (m.startsWith("audio/")) return "audio";
2247
+ if (m === "application/pdf" || m.includes("document") || m.includes("spreadsheet") || m.includes("presentation")) return "document";
2248
+ return "file";
2249
+ }
2250
+ /**
2251
+ * Map inbound user attachments to the persisted reference shape (drops the
2252
+ * presigned url — it's re-signed on demand). Every composer upload carries a
2253
+ * chat-bucket `id`, so all are persisted.
2254
+ */
2255
+ function userAttachmentsToStored(refs) {
2256
+ if (!refs?.length) return void 0;
2257
+ const out = [];
2258
+ for (const r of refs) {
2259
+ if (!r.id) continue;
2260
+ out.push({
2261
+ attachmentId: r.id,
2262
+ type: coerceStoredAttachmentType(r.type, r.mimeType),
2263
+ mimeType: r.mimeType,
2264
+ filename: r.filename ?? "file",
2265
+ ...typeof r.size === "number" && r.size > 0 ? { size: r.size } : {}
2266
+ });
2267
+ }
2268
+ return out.length ? out : void 0;
2269
+ }
2270
+ /**
2271
+ * Map resolved assistant media to persisted references. Only media uploaded to
2272
+ * our bucket (carrying `attachmentId`) is replayable — remote-passthrough media
2273
+ * has no bindable record and stays live-URL-only.
2274
+ */
2275
+ function assistantMediaToStored(media) {
2276
+ if (!media?.length) return void 0;
2277
+ const out = [];
2278
+ for (const m of media) {
2279
+ if (!m.attachmentId) continue;
2280
+ out.push({
2281
+ attachmentId: m.attachmentId,
2282
+ type: coerceStoredAttachmentType(void 0, m.mimeType),
2283
+ mimeType: m.mimeType ?? "application/octet-stream",
2284
+ filename: m.filename ?? "file",
2285
+ ...typeof m.size === "number" && m.size > 0 ? { size: m.size } : {}
2286
+ });
2287
+ }
2288
+ return out.length ? out : void 0;
2289
+ }
2290
+ /**
2291
+ * Project persisted attachment references onto the `sessions.get` wire shape.
2292
+ * `attachmentId` becomes the wire `id`; the URL is intentionally omitted — the
2293
+ * client resolves a short-lived signed URL on demand.
2294
+ */
2295
+ function replayAttachments(atts) {
2296
+ if (!atts?.length) return void 0;
2297
+ return atts.map((a) => ({
2298
+ id: a.attachmentId,
2299
+ type: a.type,
2300
+ mimeType: a.mimeType,
2301
+ filename: a.filename,
2302
+ ...typeof a.size === "number" ? { size: a.size } : {}
2303
+ }));
2304
+ }
2204
2305
  const MAX_FILE_SIZE = 50 * 1024 * 1024;
2205
2306
  const DOWNLOAD_TIMEOUT_MS = 3e4;
2206
2307
  const MAX_REDIRECTS = 5;
@@ -2450,7 +2551,7 @@ async function handleAgentRequest(request, log) {
2450
2551
  const cfg = runtime.config.loadConfig();
2451
2552
  const sessionId = conversationId ?? legacySessionKey;
2452
2553
  if (!await getSession(sessionId)) await createSession(sessionId, "", "alfe", tenantId, userId);
2453
- await addMessage(sessionId, "user", message, userId ?? senderId, displayName ?? senderId);
2554
+ await addMessage(sessionId, "user", message, userId ?? senderId, displayName ?? senderId, void 0, userAttachmentsToStored(rawAttachments));
2454
2555
  const runGate = {
2455
2556
  runId: null,
2456
2557
  sessionKey: null
@@ -2686,7 +2787,7 @@ async function handleAgentRequest(request, log) {
2686
2787
  const note = formatMediaFailureNote(media.newFailures);
2687
2788
  responseText = responseText ? `${responseText}\n\n${note}` : note;
2688
2789
  }
2689
- await addMessage(sessionId, "assistant", responseText);
2790
+ await addMessage(sessionId, "assistant", responseText, void 0, void 0, void 0, assistantMediaToStored(media.attachments));
2690
2791
  if (isA2A) a2aResponseBuffer += (a2aResponseBuffer && responseText ? "\n\n" : "") + responseText;
2691
2792
  chatClient?.notify("agent-message", {
2692
2793
  conversationId: conversationId ?? legacySessionKey,
@@ -2799,7 +2900,8 @@ async function handleSessionsGet(request, log) {
2799
2900
  role: m.role,
2800
2901
  content: m.content,
2801
2902
  timestamp: m.timestamp,
2802
- ...m.components?.length ? { components: m.components } : {}
2903
+ ...m.components?.length ? { components: m.components } : {},
2904
+ ...m.attachments?.length ? { attachments: replayAttachments(m.attachments) } : {}
2803
2905
  })),
2804
2906
  activity: await resolveHistoryActivity(session)
2805
2907
  });
@@ -2844,15 +2946,30 @@ const plugin = {
2844
2946
  }
2845
2947
  const pluginConfig = (((api.config ?? {}).plugins?.entries)?.["@alfe.ai/openclaw-chat"] ?? {}).config ?? {};
2846
2948
  const startChatService = () => {
2847
- if (globalThis.__alfeChatPluginActivated === true) {
2848
- log.debug("Chat plugin already activated — skipping duplicate");
2949
+ const action = decideChatServiceStart({
2950
+ connectInFlight: connectingPromise !== null,
2951
+ clientPresent: chatClient !== null,
2952
+ clientConnected: chatClient?.isConnected ?? false
2953
+ });
2954
+ if (action === "skip") {
2955
+ log.debug("Chat service already connected or connecting — skipping duplicate start");
2849
2956
  return;
2850
2957
  }
2958
+ if (action === "recycle") {
2959
+ log.info("Chat service client present but not connected — recycling before reconnect");
2960
+ try {
2961
+ chatClient?.stop();
2962
+ } catch (err) {
2963
+ log.debug(`Recycle of stale chat client failed: ${err instanceof Error ? err.message : String(err)}`);
2964
+ }
2965
+ chatClient = null;
2966
+ }
2851
2967
  globalThis.__alfeChatPluginActivated = true;
2852
2968
  log.info("Chat plugin registering...");
2853
2969
  resolveOpenClawSdk(log);
2854
2970
  pluginRuntime = api.runtime ?? null;
2855
- connectingPromise = Promise.resolve().then(() => {
2971
+ let thisConnect = null;
2972
+ thisConnect = Promise.resolve().then(() => {
2856
2973
  try {
2857
2974
  const { apiKey, chatWsUrl } = (0, _alfe_ai_chat.resolveAlfeChat)({
2858
2975
  apiKey: pluginConfig.apiKey,
@@ -2900,7 +3017,10 @@ const plugin = {
2900
3017
  } catch (err) {
2901
3018
  log.error(`Failed to initialize chat service: ${err instanceof Error ? err.message : String(err)}`);
2902
3019
  }
3020
+ }).finally(() => {
3021
+ if (connectingPromise === thisConnect) connectingPromise = null;
2903
3022
  });
3023
+ connectingPromise = thisConnect;
2904
3024
  };
2905
3025
  const stopChatService = async () => {
2906
3026
  globalThis.__alfeChatPluginActivated = false;
@@ -2952,7 +3072,8 @@ const plugin = {
2952
3072
  role: m.role,
2953
3073
  content: m.content,
2954
3074
  timestamp: m.timestamp,
2955
- ...m.components?.length ? { components: m.components } : {}
3075
+ ...m.components?.length ? { components: m.components } : {},
3076
+ ...m.attachments?.length ? { attachments: replayAttachments(m.attachments) } : {}
2956
3077
  })),
2957
3078
  activity: await resolveHistoryActivity(session)
2958
3079
  };
@@ -3079,6 +3200,12 @@ Object.defineProperty(exports, "createAlfeChannelPlugin", {
3079
3200
  return createAlfeChannelPlugin;
3080
3201
  }
3081
3202
  });
3203
+ Object.defineProperty(exports, "decideChatServiceStart", {
3204
+ enumerable: true,
3205
+ get: function() {
3206
+ return decideChatServiceStart;
3207
+ }
3208
+ });
3082
3209
  Object.defineProperty(exports, "joinAssistantText", {
3083
3210
  enumerable: true,
3084
3211
  get: function() {
@@ -1,2 +1,2 @@
1
- import { _ as rewriteAssistantText, a as VOICE_REPLY_SYSTEM_PROMPT, c as __setActiveRunForTest, d as buildOriginEnvelopeContext, f as buildToolActivity, g as resolveAbortTargetKeys, h as plugin, i as RunEventGate, l as admitAgentEvent, m as joinAssistantText, n as AnchorEnv, o as __agentTurnQueueForTest, p as computeOpenClawSdkAnchors, r as CROSS_AGENT_TOOLS_SYSTEM_PROMPT, s as __resetAgentTurnQueueForTest, t as ActiveRunRef, u as buildA2ACompletePayload, v as stripLeakedToolSummary } from "./plugin.cjs";
2
- export { ActiveRunRef, AnchorEnv, CROSS_AGENT_TOOLS_SYSTEM_PROMPT, RunEventGate, VOICE_REPLY_SYSTEM_PROMPT, __agentTurnQueueForTest, __resetAgentTurnQueueForTest, __setActiveRunForTest, admitAgentEvent, buildA2ACompletePayload, buildOriginEnvelopeContext, buildToolActivity, computeOpenClawSdkAnchors, plugin as default, joinAssistantText, resolveAbortTargetKeys, rewriteAssistantText, stripLeakedToolSummary };
1
+ import { _ as resolveAbortTargetKeys, a as VOICE_REPLY_SYSTEM_PROMPT, c as __setActiveRunForTest, d as buildOriginEnvelopeContext, f as buildToolActivity, g as plugin, h as joinAssistantText, i as RunEventGate, l as admitAgentEvent, m as decideChatServiceStart, n as AnchorEnv, o as __agentTurnQueueForTest, p as computeOpenClawSdkAnchors, r as CROSS_AGENT_TOOLS_SYSTEM_PROMPT, s as __resetAgentTurnQueueForTest, t as ActiveRunRef, u as buildA2ACompletePayload, v as rewriteAssistantText, y as stripLeakedToolSummary } from "./plugin.cjs";
2
+ export { ActiveRunRef, AnchorEnv, CROSS_AGENT_TOOLS_SYSTEM_PROMPT, RunEventGate, VOICE_REPLY_SYSTEM_PROMPT, __agentTurnQueueForTest, __resetAgentTurnQueueForTest, __setActiveRunForTest, admitAgentEvent, buildA2ACompletePayload, buildOriginEnvelopeContext, buildToolActivity, computeOpenClawSdkAnchors, decideChatServiceStart, plugin as default, joinAssistantText, resolveAbortTargetKeys, rewriteAssistantText, stripLeakedToolSummary };
package/dist/plugin2.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- import { _ as rewriteAssistantText, a as VOICE_REPLY_SYSTEM_PROMPT, c as __setActiveRunForTest, d as buildOriginEnvelopeContext, f as buildToolActivity, g as resolveAbortTargetKeys, h as plugin, i as RunEventGate, l as admitAgentEvent, m as joinAssistantText, n as AnchorEnv, o as __agentTurnQueueForTest, p as computeOpenClawSdkAnchors, r as CROSS_AGENT_TOOLS_SYSTEM_PROMPT, s as __resetAgentTurnQueueForTest, t as ActiveRunRef, u as buildA2ACompletePayload, v as stripLeakedToolSummary } from "./plugin.js";
2
- export { ActiveRunRef, AnchorEnv, CROSS_AGENT_TOOLS_SYSTEM_PROMPT, RunEventGate, VOICE_REPLY_SYSTEM_PROMPT, __agentTurnQueueForTest, __resetAgentTurnQueueForTest, __setActiveRunForTest, admitAgentEvent, buildA2ACompletePayload, buildOriginEnvelopeContext, buildToolActivity, computeOpenClawSdkAnchors, plugin as default, joinAssistantText, resolveAbortTargetKeys, rewriteAssistantText, stripLeakedToolSummary };
1
+ import { _ as resolveAbortTargetKeys, a as VOICE_REPLY_SYSTEM_PROMPT, c as __setActiveRunForTest, d as buildOriginEnvelopeContext, f as buildToolActivity, g as plugin, h as joinAssistantText, i as RunEventGate, l as admitAgentEvent, m as decideChatServiceStart, n as AnchorEnv, o as __agentTurnQueueForTest, p as computeOpenClawSdkAnchors, r as CROSS_AGENT_TOOLS_SYSTEM_PROMPT, s as __resetAgentTurnQueueForTest, t as ActiveRunRef, u as buildA2ACompletePayload, v as rewriteAssistantText, y as stripLeakedToolSummary } from "./plugin.js";
2
+ export { ActiveRunRef, AnchorEnv, CROSS_AGENT_TOOLS_SYSTEM_PROMPT, RunEventGate, VOICE_REPLY_SYSTEM_PROMPT, __agentTurnQueueForTest, __resetAgentTurnQueueForTest, __setActiveRunForTest, admitAgentEvent, buildA2ACompletePayload, buildOriginEnvelopeContext, buildToolActivity, computeOpenClawSdkAnchors, decideChatServiceStart, plugin as default, joinAssistantText, resolveAbortTargetKeys, rewriteAssistantText, stripLeakedToolSummary };
package/dist/plugin2.js CHANGED
@@ -210,7 +210,8 @@ async function uploadLocalFile(localPath, log, deps) {
210
210
  url: presigned.downloadUrl,
211
211
  filename,
212
212
  mimeType,
213
- size
213
+ size,
214
+ attachmentId: presigned.id
214
215
  };
215
216
  } catch (err) {
216
217
  log.warn(`Outbound media upload failed for ${filename}: ${err instanceof Error ? err.message : String(err)}`);
@@ -874,7 +875,7 @@ async function createSession(sessionId, agentId, channel, tenantId, userId) {
874
875
  return session;
875
876
  }
876
877
  const writeLocks = /* @__PURE__ */ new Map();
877
- async function addMessage(sessionId, role, content, senderId, senderName, components) {
878
+ async function addMessage(sessionId, role, content, senderId, senderName, components, attachments) {
878
879
  const next = (writeLocks.get(sessionId) ?? Promise.resolve()).then(async () => {
879
880
  const session = await getSession(sessionId);
880
881
  if (!session) return;
@@ -884,7 +885,8 @@ async function addMessage(sessionId, role, content, senderId, senderName, compon
884
885
  timestamp: Date.now(),
885
886
  ...senderId ? { senderId } : {},
886
887
  ...senderName ? { senderName } : {},
887
- ...components?.length ? { components } : {}
888
+ ...components?.length ? { components } : {},
889
+ ...attachments?.length ? { attachments } : {}
888
890
  });
889
891
  await saveSession(session);
890
892
  }).finally(() => {
@@ -2201,6 +2203,105 @@ function resolveOpenClawSdk(log) {
2201
2203
  let pluginRuntime = null;
2202
2204
  let chatClient = null;
2203
2205
  let connectingPromise = null;
2206
+ /**
2207
+ * Decide what `startChatService()` should do given the current connection
2208
+ * state. Pure so the branch logic — the load-bearing fix for the
2209
+ * health-monitor no-op restart loop (Badi Junior, 2026-07-09) — is unit
2210
+ * testable without standing up a real WebSocket or the OpenClaw SDK.
2211
+ *
2212
+ * OpenClaw calls the registered service's `start()` in two very different
2213
+ * situations that a sticky activation flag could not tell apart:
2214
+ * - a redundant duplicate (re-`activate()` per tool-discovery pass / reconnect)
2215
+ * → must be a no-op ('skip'), and
2216
+ * - a channel health-monitor REVIVE of a channel it decided is "stopped"
2217
+ * (`health-monitor: [alfe:default] restarting (reason: stopped)`), sometimes
2218
+ * with no preceding `stop()` → must actually reconnect.
2219
+ *
2220
+ * Keying on the live client fixes it:
2221
+ * - a connect in flight, or an already-connected client → 'skip'
2222
+ * - a client that exists but is not connected → 'recycle' it then reconnect
2223
+ * - no client at all → 'connect'
2224
+ *
2225
+ * Exported for unit tests.
2226
+ */
2227
+ function decideChatServiceStart(state) {
2228
+ if (state.connectInFlight) return "skip";
2229
+ if (state.clientPresent && state.clientConnected) return "skip";
2230
+ if (state.clientPresent) return "recycle";
2231
+ return "connect";
2232
+ }
2233
+ const STORED_ATTACHMENT_TYPES = new Set([
2234
+ "image",
2235
+ "video",
2236
+ "audio",
2237
+ "document",
2238
+ "file"
2239
+ ]);
2240
+ /** Narrow a possibly-loose type string, inferring from the mime as a fallback. */
2241
+ function coerceStoredAttachmentType(type, mimeType) {
2242
+ if (type && STORED_ATTACHMENT_TYPES.has(type)) return type;
2243
+ const m = mimeType ?? "";
2244
+ if (m.startsWith("image/")) return "image";
2245
+ if (m.startsWith("video/")) return "video";
2246
+ if (m.startsWith("audio/")) return "audio";
2247
+ if (m === "application/pdf" || m.includes("document") || m.includes("spreadsheet") || m.includes("presentation")) return "document";
2248
+ return "file";
2249
+ }
2250
+ /**
2251
+ * Map inbound user attachments to the persisted reference shape (drops the
2252
+ * presigned url — it's re-signed on demand). Every composer upload carries a
2253
+ * chat-bucket `id`, so all are persisted.
2254
+ */
2255
+ function userAttachmentsToStored(refs) {
2256
+ if (!refs?.length) return void 0;
2257
+ const out = [];
2258
+ for (const r of refs) {
2259
+ if (!r.id) continue;
2260
+ out.push({
2261
+ attachmentId: r.id,
2262
+ type: coerceStoredAttachmentType(r.type, r.mimeType),
2263
+ mimeType: r.mimeType,
2264
+ filename: r.filename ?? "file",
2265
+ ...typeof r.size === "number" && r.size > 0 ? { size: r.size } : {}
2266
+ });
2267
+ }
2268
+ return out.length ? out : void 0;
2269
+ }
2270
+ /**
2271
+ * Map resolved assistant media to persisted references. Only media uploaded to
2272
+ * our bucket (carrying `attachmentId`) is replayable — remote-passthrough media
2273
+ * has no bindable record and stays live-URL-only.
2274
+ */
2275
+ function assistantMediaToStored(media) {
2276
+ if (!media?.length) return void 0;
2277
+ const out = [];
2278
+ for (const m of media) {
2279
+ if (!m.attachmentId) continue;
2280
+ out.push({
2281
+ attachmentId: m.attachmentId,
2282
+ type: coerceStoredAttachmentType(void 0, m.mimeType),
2283
+ mimeType: m.mimeType ?? "application/octet-stream",
2284
+ filename: m.filename ?? "file",
2285
+ ...typeof m.size === "number" && m.size > 0 ? { size: m.size } : {}
2286
+ });
2287
+ }
2288
+ return out.length ? out : void 0;
2289
+ }
2290
+ /**
2291
+ * Project persisted attachment references onto the `sessions.get` wire shape.
2292
+ * `attachmentId` becomes the wire `id`; the URL is intentionally omitted — the
2293
+ * client resolves a short-lived signed URL on demand.
2294
+ */
2295
+ function replayAttachments(atts) {
2296
+ if (!atts?.length) return void 0;
2297
+ return atts.map((a) => ({
2298
+ id: a.attachmentId,
2299
+ type: a.type,
2300
+ mimeType: a.mimeType,
2301
+ filename: a.filename,
2302
+ ...typeof a.size === "number" ? { size: a.size } : {}
2303
+ }));
2304
+ }
2204
2305
  const MAX_FILE_SIZE = 50 * 1024 * 1024;
2205
2306
  const DOWNLOAD_TIMEOUT_MS = 3e4;
2206
2307
  const MAX_REDIRECTS = 5;
@@ -2450,7 +2551,7 @@ async function handleAgentRequest(request, log) {
2450
2551
  const cfg = runtime.config.loadConfig();
2451
2552
  const sessionId = conversationId ?? legacySessionKey;
2452
2553
  if (!await getSession(sessionId)) await createSession(sessionId, "", "alfe", tenantId, userId);
2453
- await addMessage(sessionId, "user", message, userId ?? senderId, displayName ?? senderId);
2554
+ await addMessage(sessionId, "user", message, userId ?? senderId, displayName ?? senderId, void 0, userAttachmentsToStored(rawAttachments));
2454
2555
  const runGate = {
2455
2556
  runId: null,
2456
2557
  sessionKey: null
@@ -2686,7 +2787,7 @@ async function handleAgentRequest(request, log) {
2686
2787
  const note = formatMediaFailureNote(media.newFailures);
2687
2788
  responseText = responseText ? `${responseText}\n\n${note}` : note;
2688
2789
  }
2689
- await addMessage(sessionId, "assistant", responseText);
2790
+ await addMessage(sessionId, "assistant", responseText, void 0, void 0, void 0, assistantMediaToStored(media.attachments));
2690
2791
  if (isA2A) a2aResponseBuffer += (a2aResponseBuffer && responseText ? "\n\n" : "") + responseText;
2691
2792
  chatClient?.notify("agent-message", {
2692
2793
  conversationId: conversationId ?? legacySessionKey,
@@ -2799,7 +2900,8 @@ async function handleSessionsGet(request, log) {
2799
2900
  role: m.role,
2800
2901
  content: m.content,
2801
2902
  timestamp: m.timestamp,
2802
- ...m.components?.length ? { components: m.components } : {}
2903
+ ...m.components?.length ? { components: m.components } : {},
2904
+ ...m.attachments?.length ? { attachments: replayAttachments(m.attachments) } : {}
2803
2905
  })),
2804
2906
  activity: await resolveHistoryActivity(session)
2805
2907
  });
@@ -2844,15 +2946,30 @@ const plugin = {
2844
2946
  }
2845
2947
  const pluginConfig = (((api.config ?? {}).plugins?.entries)?.["@alfe.ai/openclaw-chat"] ?? {}).config ?? {};
2846
2948
  const startChatService = () => {
2847
- if (globalThis.__alfeChatPluginActivated === true) {
2848
- log.debug("Chat plugin already activated — skipping duplicate");
2949
+ const action = decideChatServiceStart({
2950
+ connectInFlight: connectingPromise !== null,
2951
+ clientPresent: chatClient !== null,
2952
+ clientConnected: chatClient?.isConnected ?? false
2953
+ });
2954
+ if (action === "skip") {
2955
+ log.debug("Chat service already connected or connecting — skipping duplicate start");
2849
2956
  return;
2850
2957
  }
2958
+ if (action === "recycle") {
2959
+ log.info("Chat service client present but not connected — recycling before reconnect");
2960
+ try {
2961
+ chatClient?.stop();
2962
+ } catch (err) {
2963
+ log.debug(`Recycle of stale chat client failed: ${err instanceof Error ? err.message : String(err)}`);
2964
+ }
2965
+ chatClient = null;
2966
+ }
2851
2967
  globalThis.__alfeChatPluginActivated = true;
2852
2968
  log.info("Chat plugin registering...");
2853
2969
  resolveOpenClawSdk(log);
2854
2970
  pluginRuntime = api.runtime ?? null;
2855
- connectingPromise = Promise.resolve().then(() => {
2971
+ let thisConnect = null;
2972
+ thisConnect = Promise.resolve().then(() => {
2856
2973
  try {
2857
2974
  const { apiKey, chatWsUrl } = resolveAlfeChat({
2858
2975
  apiKey: pluginConfig.apiKey,
@@ -2900,7 +3017,10 @@ const plugin = {
2900
3017
  } catch (err) {
2901
3018
  log.error(`Failed to initialize chat service: ${err instanceof Error ? err.message : String(err)}`);
2902
3019
  }
3020
+ }).finally(() => {
3021
+ if (connectingPromise === thisConnect) connectingPromise = null;
2903
3022
  });
3023
+ connectingPromise = thisConnect;
2904
3024
  };
2905
3025
  const stopChatService = async () => {
2906
3026
  globalThis.__alfeChatPluginActivated = false;
@@ -2952,7 +3072,8 @@ const plugin = {
2952
3072
  role: m.role,
2953
3073
  content: m.content,
2954
3074
  timestamp: m.timestamp,
2955
- ...m.components?.length ? { components: m.components } : {}
3075
+ ...m.components?.length ? { components: m.components } : {},
3076
+ ...m.attachments?.length ? { attachments: replayAttachments(m.attachments) } : {}
2956
3077
  })),
2957
3078
  activity: await resolveHistoryActivity(session)
2958
3079
  };
@@ -3013,4 +3134,4 @@ const plugin = {
3013
3134
  }
3014
3135
  };
3015
3136
  //#endregion
3016
- export { __setActiveRunForTest as a, buildOriginEnvelopeContext as c, joinAssistantText as d, plugin as f, createAlfeChannelPlugin as g, stripLeakedToolSummary as h, __resetAgentTurnQueueForTest as i, buildToolActivity as l, rewriteAssistantText as m, VOICE_REPLY_SYSTEM_PROMPT as n, admitAgentEvent as o, resolveAbortTargetKeys as p, __agentTurnQueueForTest as r, buildA2ACompletePayload as s, CROSS_AGENT_TOOLS_SYSTEM_PROMPT as t, computeOpenClawSdkAnchors as u };
3137
+ export { createAlfeChannelPlugin as _, __setActiveRunForTest as a, buildOriginEnvelopeContext as c, decideChatServiceStart as d, joinAssistantText as f, stripLeakedToolSummary as g, rewriteAssistantText as h, __resetAgentTurnQueueForTest as i, buildToolActivity as l, resolveAbortTargetKeys as m, VOICE_REPLY_SYSTEM_PROMPT as n, admitAgentEvent as o, plugin as p, __agentTurnQueueForTest as r, buildA2ACompletePayload as s, CROSS_AGENT_TOOLS_SYSTEM_PROMPT as t, computeOpenClawSdkAnchors as u };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alfe.ai/openclaw-chat",
3
- "version": "0.9.4",
3
+ "version": "0.9.5",
4
4
  "description": "OpenClaw chat plugin for Alfe — web widget and mobile app channels",
5
5
  "type": "module",
6
6
  "main": "./dist/plugin.js",