@alfe.ai/openclaw-chat 0.9.3 → 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 +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/plugin.cjs +2 -0
- package/dist/plugin.d.cts +79 -1
- package/dist/plugin.d.ts +79 -1
- package/dist/plugin.js +2 -2
- package/dist/plugin2.cjs +242 -19
- package/dist/plugin2.d.cts +2 -2
- package/dist/plugin2.d.ts +2 -2
- package/dist/plugin2.js +232 -21
- package/package.json +2 -2
package/dist/index.d.cts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { C as
|
|
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
|
|
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 {
|
|
1
|
+
import { _ as createAlfeChannelPlugin, p as plugin } from "./plugin2.js";
|
|
2
2
|
export { createAlfeChannelPlugin, plugin as default };
|
package/dist/plugin.cjs
CHANGED
|
@@ -12,6 +12,8 @@ exports.admitAgentEvent = require_plugin.admitAgentEvent;
|
|
|
12
12
|
exports.buildA2ACompletePayload = require_plugin.buildA2ACompletePayload;
|
|
13
13
|
exports.buildOriginEnvelopeContext = require_plugin.buildOriginEnvelopeContext;
|
|
14
14
|
exports.buildToolActivity = require_plugin.buildToolActivity;
|
|
15
|
+
exports.computeOpenClawSdkAnchors = require_plugin.computeOpenClawSdkAnchors;
|
|
16
|
+
exports.decideChatServiceStart = require_plugin.decideChatServiceStart;
|
|
15
17
|
exports.default = require_plugin.plugin;
|
|
16
18
|
exports.joinAssistantText = require_plugin.joinAssistantText;
|
|
17
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
|
|
@@ -568,6 +588,64 @@ interface PluginApi {
|
|
|
568
588
|
priority?: number;
|
|
569
589
|
}): void;
|
|
570
590
|
}
|
|
591
|
+
/**
|
|
592
|
+
* Injectable seams for anchor computation — real implementations resolve
|
|
593
|
+
* symlinks and locate the `openclaw` bin. Overridable so the pure anchor
|
|
594
|
+
* logic can be unit-tested without a homebrew/openclaw layout on disk.
|
|
595
|
+
*/
|
|
596
|
+
interface AnchorEnv {
|
|
597
|
+
realpath: (p: string) => string;
|
|
598
|
+
which: (cmd: string) => string | undefined;
|
|
599
|
+
requireMainFilename: string | undefined;
|
|
600
|
+
argv1: string | undefined;
|
|
601
|
+
execPath: string;
|
|
602
|
+
}
|
|
603
|
+
/**
|
|
604
|
+
* Compute the ordered, de-duplicated list of resolver anchors to try, each
|
|
605
|
+
* best-effort `realpath`'d so a bin SYMLINK (e.g. `/opt/homebrew/bin/openclaw`
|
|
606
|
+
* → `../lib/node_modules/openclaw/openclaw.mjs`) resolves to the real module
|
|
607
|
+
* whose `createRequire` CAN find `openclaw/plugin-sdk/*`.
|
|
608
|
+
*
|
|
609
|
+
* Order (proven against homebrew-keg / relocated-node / bin-symlink layouts):
|
|
610
|
+
* 1. require.main?.filename, process.argv[1] — realpath'd (the bin symlink →
|
|
611
|
+
* real `openclaw.mjs`). argv[1] is often the symlink; realpath is what
|
|
612
|
+
* makes it a usable anchor.
|
|
613
|
+
* 2. `which openclaw` → realpath — the robust cross-setup anchor. On a
|
|
614
|
+
* homebrew keg, argv[1] is a symlink under `/opt/homebrew/bin` (no
|
|
615
|
+
* `node_modules` sibling); its realpath lands in the real
|
|
616
|
+
* `lib/node_modules/openclaw` tree where resolution succeeds.
|
|
617
|
+
* 3. execPath-derived global-modules path (the original Strategy 2), plus a
|
|
618
|
+
* realpath'd variant of the execPath prefix for relocated-node layouts.
|
|
619
|
+
*
|
|
620
|
+
* Pure except for the injected `realpath` / `which` seams — unit-tested.
|
|
621
|
+
*/
|
|
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';
|
|
571
649
|
declare function enqueueAgentTurn(task: () => Promise<void>, opts?: {
|
|
572
650
|
coalesceKey?: string;
|
|
573
651
|
onSuperseded?: () => void;
|
|
@@ -649,4 +727,4 @@ declare const plugin: {
|
|
|
649
727
|
deactivate(api: PluginApi): Promise<void>;
|
|
650
728
|
};
|
|
651
729
|
//#endregion
|
|
652
|
-
export {
|
|
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
|
|
@@ -568,6 +588,64 @@ interface PluginApi {
|
|
|
568
588
|
priority?: number;
|
|
569
589
|
}): void;
|
|
570
590
|
}
|
|
591
|
+
/**
|
|
592
|
+
* Injectable seams for anchor computation — real implementations resolve
|
|
593
|
+
* symlinks and locate the `openclaw` bin. Overridable so the pure anchor
|
|
594
|
+
* logic can be unit-tested without a homebrew/openclaw layout on disk.
|
|
595
|
+
*/
|
|
596
|
+
interface AnchorEnv {
|
|
597
|
+
realpath: (p: string) => string;
|
|
598
|
+
which: (cmd: string) => string | undefined;
|
|
599
|
+
requireMainFilename: string | undefined;
|
|
600
|
+
argv1: string | undefined;
|
|
601
|
+
execPath: string;
|
|
602
|
+
}
|
|
603
|
+
/**
|
|
604
|
+
* Compute the ordered, de-duplicated list of resolver anchors to try, each
|
|
605
|
+
* best-effort `realpath`'d so a bin SYMLINK (e.g. `/opt/homebrew/bin/openclaw`
|
|
606
|
+
* → `../lib/node_modules/openclaw/openclaw.mjs`) resolves to the real module
|
|
607
|
+
* whose `createRequire` CAN find `openclaw/plugin-sdk/*`.
|
|
608
|
+
*
|
|
609
|
+
* Order (proven against homebrew-keg / relocated-node / bin-symlink layouts):
|
|
610
|
+
* 1. require.main?.filename, process.argv[1] — realpath'd (the bin symlink →
|
|
611
|
+
* real `openclaw.mjs`). argv[1] is often the symlink; realpath is what
|
|
612
|
+
* makes it a usable anchor.
|
|
613
|
+
* 2. `which openclaw` → realpath — the robust cross-setup anchor. On a
|
|
614
|
+
* homebrew keg, argv[1] is a symlink under `/opt/homebrew/bin` (no
|
|
615
|
+
* `node_modules` sibling); its realpath lands in the real
|
|
616
|
+
* `lib/node_modules/openclaw` tree where resolution succeeds.
|
|
617
|
+
* 3. execPath-derived global-modules path (the original Strategy 2), plus a
|
|
618
|
+
* realpath'd variant of the execPath prefix for relocated-node layouts.
|
|
619
|
+
*
|
|
620
|
+
* Pure except for the injected `realpath` / `which` seams — unit-tested.
|
|
621
|
+
*/
|
|
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';
|
|
571
649
|
declare function enqueueAgentTurn(task: () => Promise<void>, opts?: {
|
|
572
650
|
coalesceKey?: string;
|
|
573
651
|
onSuperseded?: () => void;
|
|
@@ -649,4 +727,4 @@ declare const plugin: {
|
|
|
649
727
|
deactivate(api: PluginApi): Promise<void>;
|
|
650
728
|
};
|
|
651
729
|
//#endregion
|
|
652
|
-
export {
|
|
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
|
|
2
|
-
export { CROSS_AGENT_TOOLS_SYSTEM_PROMPT, VOICE_REPLY_SYSTEM_PROMPT, __agentTurnQueueForTest, __resetAgentTurnQueueForTest, __setActiveRunForTest, admitAgentEvent, buildA2ACompletePayload, buildOriginEnvelopeContext, buildToolActivity, 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
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
let node_module = require("node:module");
|
|
2
2
|
let node_fs_promises = require("node:fs/promises");
|
|
3
|
+
let node_fs = require("node:fs");
|
|
4
|
+
let node_child_process = require("node:child_process");
|
|
3
5
|
let node_path = require("node:path");
|
|
4
6
|
let node_os = require("node:os");
|
|
5
7
|
let _alfe_ai_chat = require("@alfe.ai/chat");
|
|
6
8
|
let _alfe_ai_agent_api_client = require("@alfe.ai/agent-api-client");
|
|
7
9
|
let node_crypto = require("node:crypto");
|
|
8
10
|
let _alfe_ai_config = require("@alfe.ai/config");
|
|
9
|
-
let node_fs = require("node:fs");
|
|
10
11
|
//#region src/outbound-media.ts
|
|
11
12
|
/**
|
|
12
13
|
* Outbound media upload — agent → user.
|
|
@@ -209,7 +210,8 @@ async function uploadLocalFile(localPath, log, deps) {
|
|
|
209
210
|
url: presigned.downloadUrl,
|
|
210
211
|
filename,
|
|
211
212
|
mimeType,
|
|
212
|
-
size
|
|
213
|
+
size,
|
|
214
|
+
attachmentId: presigned.id
|
|
213
215
|
};
|
|
214
216
|
} catch (err) {
|
|
215
217
|
log.warn(`Outbound media upload failed for ${filename}: ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -873,7 +875,7 @@ async function createSession(sessionId, agentId, channel, tenantId, userId) {
|
|
|
873
875
|
return session;
|
|
874
876
|
}
|
|
875
877
|
const writeLocks = /* @__PURE__ */ new Map();
|
|
876
|
-
async function addMessage(sessionId, role, content, senderId, senderName, components) {
|
|
878
|
+
async function addMessage(sessionId, role, content, senderId, senderName, components, attachments) {
|
|
877
879
|
const next = (writeLocks.get(sessionId) ?? Promise.resolve()).then(async () => {
|
|
878
880
|
const session = await getSession(sessionId);
|
|
879
881
|
if (!session) return;
|
|
@@ -883,7 +885,8 @@ async function addMessage(sessionId, role, content, senderId, senderName, compon
|
|
|
883
885
|
timestamp: Date.now(),
|
|
884
886
|
...senderId ? { senderId } : {},
|
|
885
887
|
...senderName ? { senderName } : {},
|
|
886
|
-
...components?.length ? { components } : {}
|
|
888
|
+
...components?.length ? { components } : {},
|
|
889
|
+
...attachments?.length ? { attachments } : {}
|
|
887
890
|
});
|
|
888
891
|
await saveSession(session);
|
|
889
892
|
}).finally(() => {
|
|
@@ -2091,27 +2094,214 @@ function loadOpenClawSdk(req, log, anchor) {
|
|
|
2091
2094
|
return resolvedDispatch;
|
|
2092
2095
|
}
|
|
2093
2096
|
/**
|
|
2097
|
+
* Default `which openclaw` implementation. Prefers the shell's own resolver
|
|
2098
|
+
* (`command -v` / `which`), then falls back to scanning `PATH` for an
|
|
2099
|
+
* `openclaw` executable. Returns undefined if it can't be located — every
|
|
2100
|
+
* caller treats a missing anchor as "skip", never as fatal.
|
|
2101
|
+
*/
|
|
2102
|
+
function whichOpenClaw(pathEnv) {
|
|
2103
|
+
for (const probe of [{
|
|
2104
|
+
cmd: "command",
|
|
2105
|
+
args: ["-v", "openclaw"]
|
|
2106
|
+
}, {
|
|
2107
|
+
cmd: "which",
|
|
2108
|
+
args: ["openclaw"]
|
|
2109
|
+
}]) try {
|
|
2110
|
+
const out = (0, node_child_process.execFileSync)(probe.cmd, probe.args, {
|
|
2111
|
+
encoding: "utf8",
|
|
2112
|
+
stdio: [
|
|
2113
|
+
"ignore",
|
|
2114
|
+
"pipe",
|
|
2115
|
+
"ignore"
|
|
2116
|
+
]
|
|
2117
|
+
}).trim().split("\n")[0]?.trim();
|
|
2118
|
+
if (out) return out;
|
|
2119
|
+
} catch {}
|
|
2120
|
+
for (const dir of (pathEnv ?? "").split(node_path.delimiter)) {
|
|
2121
|
+
if (!dir) continue;
|
|
2122
|
+
const candidate = (0, node_path.join)(dir, "openclaw");
|
|
2123
|
+
try {
|
|
2124
|
+
(0, node_fs.realpathSync)(candidate);
|
|
2125
|
+
return candidate;
|
|
2126
|
+
} catch {}
|
|
2127
|
+
}
|
|
2128
|
+
}
|
|
2129
|
+
/**
|
|
2130
|
+
* Compute the ordered, de-duplicated list of resolver anchors to try, each
|
|
2131
|
+
* best-effort `realpath`'d so a bin SYMLINK (e.g. `/opt/homebrew/bin/openclaw`
|
|
2132
|
+
* → `../lib/node_modules/openclaw/openclaw.mjs`) resolves to the real module
|
|
2133
|
+
* whose `createRequire` CAN find `openclaw/plugin-sdk/*`.
|
|
2134
|
+
*
|
|
2135
|
+
* Order (proven against homebrew-keg / relocated-node / bin-symlink layouts):
|
|
2136
|
+
* 1. require.main?.filename, process.argv[1] — realpath'd (the bin symlink →
|
|
2137
|
+
* real `openclaw.mjs`). argv[1] is often the symlink; realpath is what
|
|
2138
|
+
* makes it a usable anchor.
|
|
2139
|
+
* 2. `which openclaw` → realpath — the robust cross-setup anchor. On a
|
|
2140
|
+
* homebrew keg, argv[1] is a symlink under `/opt/homebrew/bin` (no
|
|
2141
|
+
* `node_modules` sibling); its realpath lands in the real
|
|
2142
|
+
* `lib/node_modules/openclaw` tree where resolution succeeds.
|
|
2143
|
+
* 3. execPath-derived global-modules path (the original Strategy 2), plus a
|
|
2144
|
+
* realpath'd variant of the execPath prefix for relocated-node layouts.
|
|
2145
|
+
*
|
|
2146
|
+
* Pure except for the injected `realpath` / `which` seams — unit-tested.
|
|
2147
|
+
*/
|
|
2148
|
+
function computeOpenClawSdkAnchors(deps) {
|
|
2149
|
+
const anchors = [];
|
|
2150
|
+
const push = (p) => {
|
|
2151
|
+
if (!p) return;
|
|
2152
|
+
let real;
|
|
2153
|
+
try {
|
|
2154
|
+
real = deps.realpath(p);
|
|
2155
|
+
} catch {}
|
|
2156
|
+
for (const candidate of [real, p]) if (candidate && !anchors.includes(candidate)) anchors.push(candidate);
|
|
2157
|
+
};
|
|
2158
|
+
push(deps.requireMainFilename);
|
|
2159
|
+
push(deps.argv1);
|
|
2160
|
+
try {
|
|
2161
|
+
push(deps.which("openclaw"));
|
|
2162
|
+
} catch {}
|
|
2163
|
+
for (const base of [deps.execPath, safeRealpath(deps.realpath, deps.execPath)]) {
|
|
2164
|
+
if (!base) continue;
|
|
2165
|
+
try {
|
|
2166
|
+
const derived = (0, node_path.join)((0, node_path.resolve)((0, node_path.dirname)(base), ".."), "lib", "node_modules", "openclaw", "package.json");
|
|
2167
|
+
if (!anchors.includes(derived)) anchors.push(derived);
|
|
2168
|
+
} catch {}
|
|
2169
|
+
}
|
|
2170
|
+
return anchors;
|
|
2171
|
+
}
|
|
2172
|
+
function safeRealpath(realpath, p) {
|
|
2173
|
+
try {
|
|
2174
|
+
const r = realpath(p);
|
|
2175
|
+
return r === p ? void 0 : r;
|
|
2176
|
+
} catch {
|
|
2177
|
+
return;
|
|
2178
|
+
}
|
|
2179
|
+
}
|
|
2180
|
+
/**
|
|
2094
2181
|
* Resolve OpenClaw SDK functions from the running process.
|
|
2095
2182
|
*
|
|
2096
2183
|
* The openclaw package is NOT in the plugin's node_modules (it's a peer dep).
|
|
2097
2184
|
* Since the plugin runs inside OpenClaw's process, we anchor resolution to
|
|
2098
|
-
* OpenClaw's own entry
|
|
2099
|
-
*
|
|
2185
|
+
* OpenClaw's own realpath'd entry (surviving bin-symlink / homebrew-keg /
|
|
2186
|
+
* relocated-node layouts), then to a `which openclaw` lookup, then fall back to
|
|
2187
|
+
* deriving the global modules path from process.execPath.
|
|
2100
2188
|
*/
|
|
2101
2189
|
function resolveOpenClawSdk(log) {
|
|
2102
|
-
const
|
|
2190
|
+
const pathEnv = process.env.PATH;
|
|
2191
|
+
const anchors = computeOpenClawSdkAnchors({
|
|
2192
|
+
realpath: node_fs.realpathSync,
|
|
2193
|
+
which: () => whichOpenClaw(pathEnv),
|
|
2194
|
+
requireMainFilename: require$1.main?.filename,
|
|
2195
|
+
argv1: process.argv[1],
|
|
2196
|
+
execPath: process.execPath
|
|
2197
|
+
});
|
|
2103
2198
|
for (const anchor of anchors) try {
|
|
2104
2199
|
if (loadOpenClawSdk((0, node_module.createRequire)(anchor), log, anchor)) return;
|
|
2105
2200
|
} catch {}
|
|
2106
|
-
try {
|
|
2107
|
-
const derivedPath = (0, node_path.join)((0, node_path.resolve)((0, node_path.dirname)(process.execPath), ".."), "lib", "node_modules", "openclaw", "package.json");
|
|
2108
|
-
if (loadOpenClawSdk((0, node_module.createRequire)(derivedPath), log, derivedPath)) return;
|
|
2109
|
-
} catch {}
|
|
2110
2201
|
log.warn("OpenClaw SDK not resolvable — chat dispatch will not work");
|
|
2111
2202
|
}
|
|
2112
2203
|
let pluginRuntime = null;
|
|
2113
2204
|
let chatClient = null;
|
|
2114
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
|
+
}
|
|
2115
2305
|
const MAX_FILE_SIZE = 50 * 1024 * 1024;
|
|
2116
2306
|
const DOWNLOAD_TIMEOUT_MS = 3e4;
|
|
2117
2307
|
const MAX_REDIRECTS = 5;
|
|
@@ -2361,7 +2551,7 @@ async function handleAgentRequest(request, log) {
|
|
|
2361
2551
|
const cfg = runtime.config.loadConfig();
|
|
2362
2552
|
const sessionId = conversationId ?? legacySessionKey;
|
|
2363
2553
|
if (!await getSession(sessionId)) await createSession(sessionId, "", "alfe", tenantId, userId);
|
|
2364
|
-
await addMessage(sessionId, "user", message, userId ?? senderId, displayName ?? senderId);
|
|
2554
|
+
await addMessage(sessionId, "user", message, userId ?? senderId, displayName ?? senderId, void 0, userAttachmentsToStored(rawAttachments));
|
|
2365
2555
|
const runGate = {
|
|
2366
2556
|
runId: null,
|
|
2367
2557
|
sessionKey: null
|
|
@@ -2597,7 +2787,7 @@ async function handleAgentRequest(request, log) {
|
|
|
2597
2787
|
const note = formatMediaFailureNote(media.newFailures);
|
|
2598
2788
|
responseText = responseText ? `${responseText}\n\n${note}` : note;
|
|
2599
2789
|
}
|
|
2600
|
-
await addMessage(sessionId, "assistant", responseText);
|
|
2790
|
+
await addMessage(sessionId, "assistant", responseText, void 0, void 0, void 0, assistantMediaToStored(media.attachments));
|
|
2601
2791
|
if (isA2A) a2aResponseBuffer += (a2aResponseBuffer && responseText ? "\n\n" : "") + responseText;
|
|
2602
2792
|
chatClient?.notify("agent-message", {
|
|
2603
2793
|
conversationId: conversationId ?? legacySessionKey,
|
|
@@ -2710,7 +2900,8 @@ async function handleSessionsGet(request, log) {
|
|
|
2710
2900
|
role: m.role,
|
|
2711
2901
|
content: m.content,
|
|
2712
2902
|
timestamp: m.timestamp,
|
|
2713
|
-
...m.components?.length ? { components: m.components } : {}
|
|
2903
|
+
...m.components?.length ? { components: m.components } : {},
|
|
2904
|
+
...m.attachments?.length ? { attachments: replayAttachments(m.attachments) } : {}
|
|
2714
2905
|
})),
|
|
2715
2906
|
activity: await resolveHistoryActivity(session)
|
|
2716
2907
|
});
|
|
@@ -2755,15 +2946,30 @@ const plugin = {
|
|
|
2755
2946
|
}
|
|
2756
2947
|
const pluginConfig = (((api.config ?? {}).plugins?.entries)?.["@alfe.ai/openclaw-chat"] ?? {}).config ?? {};
|
|
2757
2948
|
const startChatService = () => {
|
|
2758
|
-
|
|
2759
|
-
|
|
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");
|
|
2760
2956
|
return;
|
|
2761
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
|
+
}
|
|
2762
2967
|
globalThis.__alfeChatPluginActivated = true;
|
|
2763
2968
|
log.info("Chat plugin registering...");
|
|
2764
2969
|
resolveOpenClawSdk(log);
|
|
2765
2970
|
pluginRuntime = api.runtime ?? null;
|
|
2766
|
-
|
|
2971
|
+
let thisConnect = null;
|
|
2972
|
+
thisConnect = Promise.resolve().then(() => {
|
|
2767
2973
|
try {
|
|
2768
2974
|
const { apiKey, chatWsUrl } = (0, _alfe_ai_chat.resolveAlfeChat)({
|
|
2769
2975
|
apiKey: pluginConfig.apiKey,
|
|
@@ -2811,7 +3017,10 @@ const plugin = {
|
|
|
2811
3017
|
} catch (err) {
|
|
2812
3018
|
log.error(`Failed to initialize chat service: ${err instanceof Error ? err.message : String(err)}`);
|
|
2813
3019
|
}
|
|
3020
|
+
}).finally(() => {
|
|
3021
|
+
if (connectingPromise === thisConnect) connectingPromise = null;
|
|
2814
3022
|
});
|
|
3023
|
+
connectingPromise = thisConnect;
|
|
2815
3024
|
};
|
|
2816
3025
|
const stopChatService = async () => {
|
|
2817
3026
|
globalThis.__alfeChatPluginActivated = false;
|
|
@@ -2863,13 +3072,15 @@ const plugin = {
|
|
|
2863
3072
|
role: m.role,
|
|
2864
3073
|
content: m.content,
|
|
2865
3074
|
timestamp: m.timestamp,
|
|
2866
|
-
...m.components?.length ? { components: m.components } : {}
|
|
3075
|
+
...m.components?.length ? { components: m.components } : {},
|
|
3076
|
+
...m.attachments?.length ? { attachments: replayAttachments(m.attachments) } : {}
|
|
2867
3077
|
})),
|
|
2868
3078
|
activity: await resolveHistoryActivity(session)
|
|
2869
3079
|
};
|
|
2870
3080
|
});
|
|
3081
|
+
api.registerGatewayMethod("runtime.activity", () => Promise.resolve({ active: activeRun !== null }));
|
|
2871
3082
|
gw.__alfeChatGatewayMethodsRegistered = true;
|
|
2872
|
-
log.info("Registered gateway RPC methods: sessions.list, sessions.get");
|
|
3083
|
+
log.info("Registered gateway RPC methods: sessions.list, sessions.get, runtime.activity");
|
|
2873
3084
|
}
|
|
2874
3085
|
api.on("session_start", async (...eventArgs) => {
|
|
2875
3086
|
if (!pluginRuntime) return;
|
|
@@ -2977,12 +3188,24 @@ Object.defineProperty(exports, "buildToolActivity", {
|
|
|
2977
3188
|
return buildToolActivity;
|
|
2978
3189
|
}
|
|
2979
3190
|
});
|
|
3191
|
+
Object.defineProperty(exports, "computeOpenClawSdkAnchors", {
|
|
3192
|
+
enumerable: true,
|
|
3193
|
+
get: function() {
|
|
3194
|
+
return computeOpenClawSdkAnchors;
|
|
3195
|
+
}
|
|
3196
|
+
});
|
|
2980
3197
|
Object.defineProperty(exports, "createAlfeChannelPlugin", {
|
|
2981
3198
|
enumerable: true,
|
|
2982
3199
|
get: function() {
|
|
2983
3200
|
return createAlfeChannelPlugin;
|
|
2984
3201
|
}
|
|
2985
3202
|
});
|
|
3203
|
+
Object.defineProperty(exports, "decideChatServiceStart", {
|
|
3204
|
+
enumerable: true,
|
|
3205
|
+
get: function() {
|
|
3206
|
+
return decideChatServiceStart;
|
|
3207
|
+
}
|
|
3208
|
+
});
|
|
2986
3209
|
Object.defineProperty(exports, "joinAssistantText", {
|
|
2987
3210
|
enumerable: true,
|
|
2988
3211
|
get: function() {
|
package/dist/plugin2.d.cts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { a as
|
|
2
|
-
export { ActiveRunRef, CROSS_AGENT_TOOLS_SYSTEM_PROMPT, RunEventGate, VOICE_REPLY_SYSTEM_PROMPT, __agentTurnQueueForTest, __resetAgentTurnQueueForTest, __setActiveRunForTest, admitAgentEvent, buildA2ACompletePayload, buildOriginEnvelopeContext, buildToolActivity, 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 { a as
|
|
2
|
-
export { ActiveRunRef, CROSS_AGENT_TOOLS_SYSTEM_PROMPT, RunEventGate, VOICE_REPLY_SYSTEM_PROMPT, __agentTurnQueueForTest, __resetAgentTurnQueueForTest, __setActiveRunForTest, admitAgentEvent, buildA2ACompletePayload, buildOriginEnvelopeContext, buildToolActivity, 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
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { createRequire } from "node:module";
|
|
2
2
|
import { mkdir, open, readFile, readdir, stat, unlink, writeFile } from "node:fs/promises";
|
|
3
|
-
import {
|
|
3
|
+
import { existsSync, realpathSync } from "node:fs";
|
|
4
|
+
import { execFileSync } from "node:child_process";
|
|
5
|
+
import { basename, delimiter, dirname, isAbsolute, join, resolve } from "node:path";
|
|
4
6
|
import { homedir } from "node:os";
|
|
5
7
|
import { ChatServiceClient, resolveAlfeChat } from "@alfe.ai/chat";
|
|
6
8
|
import { AgentApiClient, installToolErrorCapture } from "@alfe.ai/agent-api-client";
|
|
7
9
|
import { randomUUID } from "node:crypto";
|
|
8
10
|
import { resolveConfig } from "@alfe.ai/config";
|
|
9
|
-
import { existsSync } from "node:fs";
|
|
10
11
|
//#region src/outbound-media.ts
|
|
11
12
|
/**
|
|
12
13
|
* Outbound media upload — agent → user.
|
|
@@ -209,7 +210,8 @@ async function uploadLocalFile(localPath, log, deps) {
|
|
|
209
210
|
url: presigned.downloadUrl,
|
|
210
211
|
filename,
|
|
211
212
|
mimeType,
|
|
212
|
-
size
|
|
213
|
+
size,
|
|
214
|
+
attachmentId: presigned.id
|
|
213
215
|
};
|
|
214
216
|
} catch (err) {
|
|
215
217
|
log.warn(`Outbound media upload failed for ${filename}: ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -873,7 +875,7 @@ async function createSession(sessionId, agentId, channel, tenantId, userId) {
|
|
|
873
875
|
return session;
|
|
874
876
|
}
|
|
875
877
|
const writeLocks = /* @__PURE__ */ new Map();
|
|
876
|
-
async function addMessage(sessionId, role, content, senderId, senderName, components) {
|
|
878
|
+
async function addMessage(sessionId, role, content, senderId, senderName, components, attachments) {
|
|
877
879
|
const next = (writeLocks.get(sessionId) ?? Promise.resolve()).then(async () => {
|
|
878
880
|
const session = await getSession(sessionId);
|
|
879
881
|
if (!session) return;
|
|
@@ -883,7 +885,8 @@ async function addMessage(sessionId, role, content, senderId, senderName, compon
|
|
|
883
885
|
timestamp: Date.now(),
|
|
884
886
|
...senderId ? { senderId } : {},
|
|
885
887
|
...senderName ? { senderName } : {},
|
|
886
|
-
...components?.length ? { components } : {}
|
|
888
|
+
...components?.length ? { components } : {},
|
|
889
|
+
...attachments?.length ? { attachments } : {}
|
|
887
890
|
});
|
|
888
891
|
await saveSession(session);
|
|
889
892
|
}).finally(() => {
|
|
@@ -2091,27 +2094,214 @@ function loadOpenClawSdk(req, log, anchor) {
|
|
|
2091
2094
|
return resolvedDispatch;
|
|
2092
2095
|
}
|
|
2093
2096
|
/**
|
|
2097
|
+
* Default `which openclaw` implementation. Prefers the shell's own resolver
|
|
2098
|
+
* (`command -v` / `which`), then falls back to scanning `PATH` for an
|
|
2099
|
+
* `openclaw` executable. Returns undefined if it can't be located — every
|
|
2100
|
+
* caller treats a missing anchor as "skip", never as fatal.
|
|
2101
|
+
*/
|
|
2102
|
+
function whichOpenClaw(pathEnv) {
|
|
2103
|
+
for (const probe of [{
|
|
2104
|
+
cmd: "command",
|
|
2105
|
+
args: ["-v", "openclaw"]
|
|
2106
|
+
}, {
|
|
2107
|
+
cmd: "which",
|
|
2108
|
+
args: ["openclaw"]
|
|
2109
|
+
}]) try {
|
|
2110
|
+
const out = execFileSync(probe.cmd, probe.args, {
|
|
2111
|
+
encoding: "utf8",
|
|
2112
|
+
stdio: [
|
|
2113
|
+
"ignore",
|
|
2114
|
+
"pipe",
|
|
2115
|
+
"ignore"
|
|
2116
|
+
]
|
|
2117
|
+
}).trim().split("\n")[0]?.trim();
|
|
2118
|
+
if (out) return out;
|
|
2119
|
+
} catch {}
|
|
2120
|
+
for (const dir of (pathEnv ?? "").split(delimiter)) {
|
|
2121
|
+
if (!dir) continue;
|
|
2122
|
+
const candidate = join(dir, "openclaw");
|
|
2123
|
+
try {
|
|
2124
|
+
realpathSync(candidate);
|
|
2125
|
+
return candidate;
|
|
2126
|
+
} catch {}
|
|
2127
|
+
}
|
|
2128
|
+
}
|
|
2129
|
+
/**
|
|
2130
|
+
* Compute the ordered, de-duplicated list of resolver anchors to try, each
|
|
2131
|
+
* best-effort `realpath`'d so a bin SYMLINK (e.g. `/opt/homebrew/bin/openclaw`
|
|
2132
|
+
* → `../lib/node_modules/openclaw/openclaw.mjs`) resolves to the real module
|
|
2133
|
+
* whose `createRequire` CAN find `openclaw/plugin-sdk/*`.
|
|
2134
|
+
*
|
|
2135
|
+
* Order (proven against homebrew-keg / relocated-node / bin-symlink layouts):
|
|
2136
|
+
* 1. require.main?.filename, process.argv[1] — realpath'd (the bin symlink →
|
|
2137
|
+
* real `openclaw.mjs`). argv[1] is often the symlink; realpath is what
|
|
2138
|
+
* makes it a usable anchor.
|
|
2139
|
+
* 2. `which openclaw` → realpath — the robust cross-setup anchor. On a
|
|
2140
|
+
* homebrew keg, argv[1] is a symlink under `/opt/homebrew/bin` (no
|
|
2141
|
+
* `node_modules` sibling); its realpath lands in the real
|
|
2142
|
+
* `lib/node_modules/openclaw` tree where resolution succeeds.
|
|
2143
|
+
* 3. execPath-derived global-modules path (the original Strategy 2), plus a
|
|
2144
|
+
* realpath'd variant of the execPath prefix for relocated-node layouts.
|
|
2145
|
+
*
|
|
2146
|
+
* Pure except for the injected `realpath` / `which` seams — unit-tested.
|
|
2147
|
+
*/
|
|
2148
|
+
function computeOpenClawSdkAnchors(deps) {
|
|
2149
|
+
const anchors = [];
|
|
2150
|
+
const push = (p) => {
|
|
2151
|
+
if (!p) return;
|
|
2152
|
+
let real;
|
|
2153
|
+
try {
|
|
2154
|
+
real = deps.realpath(p);
|
|
2155
|
+
} catch {}
|
|
2156
|
+
for (const candidate of [real, p]) if (candidate && !anchors.includes(candidate)) anchors.push(candidate);
|
|
2157
|
+
};
|
|
2158
|
+
push(deps.requireMainFilename);
|
|
2159
|
+
push(deps.argv1);
|
|
2160
|
+
try {
|
|
2161
|
+
push(deps.which("openclaw"));
|
|
2162
|
+
} catch {}
|
|
2163
|
+
for (const base of [deps.execPath, safeRealpath(deps.realpath, deps.execPath)]) {
|
|
2164
|
+
if (!base) continue;
|
|
2165
|
+
try {
|
|
2166
|
+
const derived = join(resolve(dirname(base), ".."), "lib", "node_modules", "openclaw", "package.json");
|
|
2167
|
+
if (!anchors.includes(derived)) anchors.push(derived);
|
|
2168
|
+
} catch {}
|
|
2169
|
+
}
|
|
2170
|
+
return anchors;
|
|
2171
|
+
}
|
|
2172
|
+
function safeRealpath(realpath, p) {
|
|
2173
|
+
try {
|
|
2174
|
+
const r = realpath(p);
|
|
2175
|
+
return r === p ? void 0 : r;
|
|
2176
|
+
} catch {
|
|
2177
|
+
return;
|
|
2178
|
+
}
|
|
2179
|
+
}
|
|
2180
|
+
/**
|
|
2094
2181
|
* Resolve OpenClaw SDK functions from the running process.
|
|
2095
2182
|
*
|
|
2096
2183
|
* The openclaw package is NOT in the plugin's node_modules (it's a peer dep).
|
|
2097
2184
|
* Since the plugin runs inside OpenClaw's process, we anchor resolution to
|
|
2098
|
-
* OpenClaw's own entry
|
|
2099
|
-
*
|
|
2185
|
+
* OpenClaw's own realpath'd entry (surviving bin-symlink / homebrew-keg /
|
|
2186
|
+
* relocated-node layouts), then to a `which openclaw` lookup, then fall back to
|
|
2187
|
+
* deriving the global modules path from process.execPath.
|
|
2100
2188
|
*/
|
|
2101
2189
|
function resolveOpenClawSdk(log) {
|
|
2102
|
-
const
|
|
2190
|
+
const pathEnv = process.env.PATH;
|
|
2191
|
+
const anchors = computeOpenClawSdkAnchors({
|
|
2192
|
+
realpath: realpathSync,
|
|
2193
|
+
which: () => whichOpenClaw(pathEnv),
|
|
2194
|
+
requireMainFilename: require.main?.filename,
|
|
2195
|
+
argv1: process.argv[1],
|
|
2196
|
+
execPath: process.execPath
|
|
2197
|
+
});
|
|
2103
2198
|
for (const anchor of anchors) try {
|
|
2104
2199
|
if (loadOpenClawSdk(createRequire(anchor), log, anchor)) return;
|
|
2105
2200
|
} catch {}
|
|
2106
|
-
try {
|
|
2107
|
-
const derivedPath = join(resolve(dirname(process.execPath), ".."), "lib", "node_modules", "openclaw", "package.json");
|
|
2108
|
-
if (loadOpenClawSdk(createRequire(derivedPath), log, derivedPath)) return;
|
|
2109
|
-
} catch {}
|
|
2110
2201
|
log.warn("OpenClaw SDK not resolvable — chat dispatch will not work");
|
|
2111
2202
|
}
|
|
2112
2203
|
let pluginRuntime = null;
|
|
2113
2204
|
let chatClient = null;
|
|
2114
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
|
+
}
|
|
2115
2305
|
const MAX_FILE_SIZE = 50 * 1024 * 1024;
|
|
2116
2306
|
const DOWNLOAD_TIMEOUT_MS = 3e4;
|
|
2117
2307
|
const MAX_REDIRECTS = 5;
|
|
@@ -2361,7 +2551,7 @@ async function handleAgentRequest(request, log) {
|
|
|
2361
2551
|
const cfg = runtime.config.loadConfig();
|
|
2362
2552
|
const sessionId = conversationId ?? legacySessionKey;
|
|
2363
2553
|
if (!await getSession(sessionId)) await createSession(sessionId, "", "alfe", tenantId, userId);
|
|
2364
|
-
await addMessage(sessionId, "user", message, userId ?? senderId, displayName ?? senderId);
|
|
2554
|
+
await addMessage(sessionId, "user", message, userId ?? senderId, displayName ?? senderId, void 0, userAttachmentsToStored(rawAttachments));
|
|
2365
2555
|
const runGate = {
|
|
2366
2556
|
runId: null,
|
|
2367
2557
|
sessionKey: null
|
|
@@ -2597,7 +2787,7 @@ async function handleAgentRequest(request, log) {
|
|
|
2597
2787
|
const note = formatMediaFailureNote(media.newFailures);
|
|
2598
2788
|
responseText = responseText ? `${responseText}\n\n${note}` : note;
|
|
2599
2789
|
}
|
|
2600
|
-
await addMessage(sessionId, "assistant", responseText);
|
|
2790
|
+
await addMessage(sessionId, "assistant", responseText, void 0, void 0, void 0, assistantMediaToStored(media.attachments));
|
|
2601
2791
|
if (isA2A) a2aResponseBuffer += (a2aResponseBuffer && responseText ? "\n\n" : "") + responseText;
|
|
2602
2792
|
chatClient?.notify("agent-message", {
|
|
2603
2793
|
conversationId: conversationId ?? legacySessionKey,
|
|
@@ -2710,7 +2900,8 @@ async function handleSessionsGet(request, log) {
|
|
|
2710
2900
|
role: m.role,
|
|
2711
2901
|
content: m.content,
|
|
2712
2902
|
timestamp: m.timestamp,
|
|
2713
|
-
...m.components?.length ? { components: m.components } : {}
|
|
2903
|
+
...m.components?.length ? { components: m.components } : {},
|
|
2904
|
+
...m.attachments?.length ? { attachments: replayAttachments(m.attachments) } : {}
|
|
2714
2905
|
})),
|
|
2715
2906
|
activity: await resolveHistoryActivity(session)
|
|
2716
2907
|
});
|
|
@@ -2755,15 +2946,30 @@ const plugin = {
|
|
|
2755
2946
|
}
|
|
2756
2947
|
const pluginConfig = (((api.config ?? {}).plugins?.entries)?.["@alfe.ai/openclaw-chat"] ?? {}).config ?? {};
|
|
2757
2948
|
const startChatService = () => {
|
|
2758
|
-
|
|
2759
|
-
|
|
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");
|
|
2760
2956
|
return;
|
|
2761
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
|
+
}
|
|
2762
2967
|
globalThis.__alfeChatPluginActivated = true;
|
|
2763
2968
|
log.info("Chat plugin registering...");
|
|
2764
2969
|
resolveOpenClawSdk(log);
|
|
2765
2970
|
pluginRuntime = api.runtime ?? null;
|
|
2766
|
-
|
|
2971
|
+
let thisConnect = null;
|
|
2972
|
+
thisConnect = Promise.resolve().then(() => {
|
|
2767
2973
|
try {
|
|
2768
2974
|
const { apiKey, chatWsUrl } = resolveAlfeChat({
|
|
2769
2975
|
apiKey: pluginConfig.apiKey,
|
|
@@ -2811,7 +3017,10 @@ const plugin = {
|
|
|
2811
3017
|
} catch (err) {
|
|
2812
3018
|
log.error(`Failed to initialize chat service: ${err instanceof Error ? err.message : String(err)}`);
|
|
2813
3019
|
}
|
|
3020
|
+
}).finally(() => {
|
|
3021
|
+
if (connectingPromise === thisConnect) connectingPromise = null;
|
|
2814
3022
|
});
|
|
3023
|
+
connectingPromise = thisConnect;
|
|
2815
3024
|
};
|
|
2816
3025
|
const stopChatService = async () => {
|
|
2817
3026
|
globalThis.__alfeChatPluginActivated = false;
|
|
@@ -2863,13 +3072,15 @@ const plugin = {
|
|
|
2863
3072
|
role: m.role,
|
|
2864
3073
|
content: m.content,
|
|
2865
3074
|
timestamp: m.timestamp,
|
|
2866
|
-
...m.components?.length ? { components: m.components } : {}
|
|
3075
|
+
...m.components?.length ? { components: m.components } : {},
|
|
3076
|
+
...m.attachments?.length ? { attachments: replayAttachments(m.attachments) } : {}
|
|
2867
3077
|
})),
|
|
2868
3078
|
activity: await resolveHistoryActivity(session)
|
|
2869
3079
|
};
|
|
2870
3080
|
});
|
|
3081
|
+
api.registerGatewayMethod("runtime.activity", () => Promise.resolve({ active: activeRun !== null }));
|
|
2871
3082
|
gw.__alfeChatGatewayMethodsRegistered = true;
|
|
2872
|
-
log.info("Registered gateway RPC methods: sessions.list, sessions.get");
|
|
3083
|
+
log.info("Registered gateway RPC methods: sessions.list, sessions.get, runtime.activity");
|
|
2873
3084
|
}
|
|
2874
3085
|
api.on("session_start", async (...eventArgs) => {
|
|
2875
3086
|
if (!pluginRuntime) return;
|
|
@@ -2923,4 +3134,4 @@ const plugin = {
|
|
|
2923
3134
|
}
|
|
2924
3135
|
};
|
|
2925
3136
|
//#endregion
|
|
2926
|
-
export { __setActiveRunForTest as a, buildOriginEnvelopeContext as c,
|
|
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.
|
|
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",
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"openclaw.plugin.json"
|
|
28
28
|
],
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"@alfe.ai/agent-api-client": "^0.
|
|
30
|
+
"@alfe.ai/agent-api-client": "^0.10.0",
|
|
31
31
|
"@alfe.ai/chat": "^0.0.16",
|
|
32
32
|
"@alfe.ai/config": "^0.3.0"
|
|
33
33
|
},
|