@pnds/pond 0.1.1 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/channel.ts +1 -1
- package/src/config-manager.ts +150 -0
- package/src/gateway.ts +183 -63
- package/src/hooks.ts +45 -25
- package/src/outbound.ts +1 -0
- package/src/runtime.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pnds/pond",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "1.0.0",
|
|
4
4
|
"description": "OpenClaw channel plugin for Pond IM",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"openclaw.plugin.json"
|
|
15
15
|
],
|
|
16
16
|
"dependencies": {
|
|
17
|
-
"@pnds/sdk": "
|
|
17
|
+
"@pnds/sdk": "1.0.0"
|
|
18
18
|
},
|
|
19
19
|
"devDependencies": {
|
|
20
20
|
"typescript": "^5.7.0"
|
package/src/channel.ts
CHANGED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import type { PondClient } from "@pnds/sdk";
|
|
2
|
+
import type { PlatformConfigResponse } from "@pnds/sdk";
|
|
3
|
+
import * as fs from "node:fs";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
|
|
6
|
+
interface CachedPlatformConfig {
|
|
7
|
+
version: string;
|
|
8
|
+
config: Record<string, unknown>;
|
|
9
|
+
fetchedAt: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface ConfigManagerOpts {
|
|
13
|
+
client: PondClient;
|
|
14
|
+
stateDir: string;
|
|
15
|
+
configPath: string;
|
|
16
|
+
credentials: { api_key: string; pond_url: string };
|
|
17
|
+
log?: { info: (msg: string) => void; warn: (msg: string) => void; error: (msg: string) => void };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const CACHE_FILENAME = "pond-platform-config.json";
|
|
21
|
+
|
|
22
|
+
function cachePath(stateDir: string): string {
|
|
23
|
+
return path.join(stateDir, CACHE_FILENAME);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function loadCachedConfig(stateDir: string): CachedPlatformConfig | null {
|
|
27
|
+
try {
|
|
28
|
+
const raw = fs.readFileSync(cachePath(stateDir), "utf-8");
|
|
29
|
+
return JSON.parse(raw) as CachedPlatformConfig;
|
|
30
|
+
} catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function saveCachedConfig(stateDir: string, config: PlatformConfigResponse): void {
|
|
36
|
+
const cached: CachedPlatformConfig = {
|
|
37
|
+
version: config.version,
|
|
38
|
+
config: config.config,
|
|
39
|
+
fetchedAt: new Date().toISOString(),
|
|
40
|
+
};
|
|
41
|
+
const filePath = cachePath(stateDir);
|
|
42
|
+
const tmpPath = `${filePath}.tmp`;
|
|
43
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
44
|
+
fs.writeFileSync(tmpPath, JSON.stringify(cached, null, 2), "utf-8");
|
|
45
|
+
fs.renameSync(tmpPath, filePath);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Deep-merge platform config into an existing openclaw.json file.
|
|
50
|
+
* Only overwrites `models.providers.pond` and `agents.defaults` sections;
|
|
51
|
+
* all other keys (user customizations, other providers) are preserved.
|
|
52
|
+
*/
|
|
53
|
+
function applyToOpenClawConfig(
|
|
54
|
+
configPath: string,
|
|
55
|
+
platformConfig: Record<string, unknown>,
|
|
56
|
+
credentials: { api_key: string; pond_url: string },
|
|
57
|
+
): void {
|
|
58
|
+
let existing: Record<string, unknown> = {};
|
|
59
|
+
try {
|
|
60
|
+
const raw = fs.readFileSync(configPath, "utf-8");
|
|
61
|
+
existing = JSON.parse(raw) as Record<string, unknown>;
|
|
62
|
+
} catch {
|
|
63
|
+
// File doesn't exist or is invalid — start fresh
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Deep-merge models.providers.pond (preserve existing fields, overlay platform, inject credentials)
|
|
67
|
+
const models = (existing.models ?? {}) as Record<string, unknown>;
|
|
68
|
+
const providers = (models.providers ?? {}) as Record<string, unknown>;
|
|
69
|
+
const existingPond = (providers.pond ?? {}) as Record<string, unknown>;
|
|
70
|
+
const platformModels = (platformConfig.models ?? {}) as Record<string, unknown>;
|
|
71
|
+
const platformProviders = (platformModels.providers ?? {}) as Record<string, unknown>;
|
|
72
|
+
const platformPond = (platformProviders.pond ?? {}) as Record<string, unknown>;
|
|
73
|
+
|
|
74
|
+
providers.pond = {
|
|
75
|
+
...existingPond,
|
|
76
|
+
...platformPond,
|
|
77
|
+
apiKey: credentials.api_key,
|
|
78
|
+
};
|
|
79
|
+
models.providers = providers;
|
|
80
|
+
existing.models = models;
|
|
81
|
+
|
|
82
|
+
// Deep-merge agents.defaults (preserve existing fields, overlay platform)
|
|
83
|
+
const platformAgents = (platformConfig.agents ?? {}) as Record<string, unknown>;
|
|
84
|
+
if (platformAgents.defaults !== undefined) {
|
|
85
|
+
const agents = (existing.agents ?? {}) as Record<string, unknown>;
|
|
86
|
+
const existingDefaults = (agents.defaults ?? {}) as Record<string, unknown>;
|
|
87
|
+
agents.defaults = {
|
|
88
|
+
...existingDefaults,
|
|
89
|
+
...(platformAgents.defaults as Record<string, unknown>),
|
|
90
|
+
};
|
|
91
|
+
existing.agents = agents;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Atomic write
|
|
95
|
+
const tmpPath = `${configPath}.tmp`;
|
|
96
|
+
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
|
97
|
+
fs.writeFileSync(tmpPath, JSON.stringify(existing, null, 2), "utf-8");
|
|
98
|
+
fs.renameSync(tmpPath, configPath);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Fetch platform config from the server, cache it (LKG), and apply to openclaw.json.
|
|
103
|
+
* Degrades gracefully: uses cached config on fetch failure, continues if neither is available.
|
|
104
|
+
*/
|
|
105
|
+
export async function fetchAndApplyPlatformConfig(opts: ConfigManagerOpts): Promise<void> {
|
|
106
|
+
const { client, stateDir, configPath, credentials, log } = opts;
|
|
107
|
+
|
|
108
|
+
// 1. Load cached config (LKG)
|
|
109
|
+
const cached = loadCachedConfig(stateDir);
|
|
110
|
+
|
|
111
|
+
// 2. Fetch from server (with version for 304)
|
|
112
|
+
let fresh: PlatformConfigResponse | null = null;
|
|
113
|
+
try {
|
|
114
|
+
fresh = await client.getPlatformConfig(cached?.version);
|
|
115
|
+
} catch (err) {
|
|
116
|
+
// Fetch failed — fall through to use cache
|
|
117
|
+
if (cached) {
|
|
118
|
+
log?.warn(`platform config fetch failed, using cached version ${cached.version}: ${String(err)}`);
|
|
119
|
+
applyToOpenClawConfig(configPath, cached.config, credentials);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
// No cache and fetch fails — degrade gracefully
|
|
123
|
+
log?.error(`platform config fetch failed and no cache available: ${String(err)}`);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// 3. 304 — config unchanged
|
|
128
|
+
if (fresh === null) {
|
|
129
|
+
log?.info("platform config unchanged (304)");
|
|
130
|
+
if (cached) {
|
|
131
|
+
applyToOpenClawConfig(configPath, cached.config, credentials);
|
|
132
|
+
}
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// 4. Validate schema compatibility before accepting
|
|
137
|
+
const SUPPORTED_SCHEMA_VERSION = 1;
|
|
138
|
+
if (fresh.schema_version !== undefined && fresh.schema_version > SUPPORTED_SCHEMA_VERSION) {
|
|
139
|
+
log?.error(`platform config schema_version ${fresh.schema_version} is newer than supported (${SUPPORTED_SCHEMA_VERSION}), keeping current config`);
|
|
140
|
+
if (cached) {
|
|
141
|
+
applyToOpenClawConfig(configPath, cached.config, credentials);
|
|
142
|
+
}
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// 5. Newer config received — save cache and apply
|
|
147
|
+
log?.info(`platform config updated to version ${fresh.version}`);
|
|
148
|
+
saveCachedConfig(stateDir, fresh);
|
|
149
|
+
applyToOpenClawConfig(configPath, fresh.config, credentials);
|
|
150
|
+
}
|
package/src/gateway.ts
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
|
-
import type { ChannelGatewayAdapter } from "openclaw/plugin-sdk";
|
|
1
|
+
import type { ChannelGatewayAdapter, PluginRuntime } from "openclaw/plugin-sdk";
|
|
2
2
|
import { PondClient, PondWs, MENTION_ALL_USER_ID, WS_EVENTS } from "@pnds/sdk";
|
|
3
|
-
import type { Chat, MessageNewData,
|
|
3
|
+
import type { Chat, MessageNewData, TextContent, MediaContent, HelloData, ChatUpdateData, AgentConfigUpdateData } from "@pnds/sdk";
|
|
4
|
+
import type { PondChannelConfig } from "./types.js";
|
|
4
5
|
import * as os from "node:os";
|
|
6
|
+
import * as path from "node:path";
|
|
5
7
|
import { resolvePondAccount } from "./accounts.js";
|
|
6
|
-
import { getPondRuntime, setPondAccountState, removePondAccountState, setSessionChatId } from "./runtime.js";
|
|
8
|
+
import { getPondRuntime, setPondAccountState, getPondAccountState, removePondAccountState, setSessionChatId } from "./runtime.js";
|
|
7
9
|
import { buildSessionKey } from "./session.js";
|
|
8
10
|
import type { ResolvedPondAccount } from "./types.js";
|
|
11
|
+
import { fetchAndApplyPlatformConfig } from "./config-manager.js";
|
|
9
12
|
|
|
10
13
|
function resolveWsUrl(account: ResolvedPondAccount): string {
|
|
11
14
|
if (account.config.ws_url) return account.config.ws_url;
|
|
@@ -35,6 +38,71 @@ async function fetchAllChats(
|
|
|
35
38
|
return total;
|
|
36
39
|
}
|
|
37
40
|
|
|
41
|
+
/**
|
|
42
|
+
* Dispatch an inbound message to the OpenClaw agent and handle the reply.
|
|
43
|
+
* Shared by both text and file message handlers to avoid duplication.
|
|
44
|
+
*/
|
|
45
|
+
async function dispatchToAgent(opts: {
|
|
46
|
+
core: PluginRuntime;
|
|
47
|
+
cfg: Record<string, unknown>;
|
|
48
|
+
client: PondClient;
|
|
49
|
+
config: PondChannelConfig;
|
|
50
|
+
agentUserId: string;
|
|
51
|
+
accountId: string;
|
|
52
|
+
chatId: string;
|
|
53
|
+
messageId: string;
|
|
54
|
+
inboundCtx: Record<string, unknown>;
|
|
55
|
+
log?: { info: (msg: string) => void; warn: (msg: string) => void; error: (msg: string) => void };
|
|
56
|
+
}) {
|
|
57
|
+
const { core, cfg, client, config, agentUserId, accountId, chatId, messageId, inboundCtx, log } = opts;
|
|
58
|
+
let thinkingSent = false;
|
|
59
|
+
let runIdPromise: Promise<string | undefined> | undefined;
|
|
60
|
+
try {
|
|
61
|
+
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
62
|
+
ctx: inboundCtx,
|
|
63
|
+
cfg,
|
|
64
|
+
dispatcherOptions: {
|
|
65
|
+
deliver: async (payload: { text?: string }) => {
|
|
66
|
+
const replyText = payload.text?.trim();
|
|
67
|
+
if (!replyText) return;
|
|
68
|
+
// Wait for run creation to complete before sending
|
|
69
|
+
const runId = runIdPromise ? await runIdPromise : undefined;
|
|
70
|
+
await client.sendMessage(config.org_id, chatId, {
|
|
71
|
+
message_type: "text",
|
|
72
|
+
content: { text: replyText },
|
|
73
|
+
agent_run_id: runId,
|
|
74
|
+
});
|
|
75
|
+
},
|
|
76
|
+
onReplyStart: () => {
|
|
77
|
+
if (thinkingSent) return;
|
|
78
|
+
thinkingSent = true;
|
|
79
|
+
runIdPromise = (async () => {
|
|
80
|
+
try {
|
|
81
|
+
const run = await client.createAgentRun(config.org_id, agentUserId, {
|
|
82
|
+
trigger_type: "mention",
|
|
83
|
+
trigger_ref: { message_id: messageId },
|
|
84
|
+
chat_id: chatId,
|
|
85
|
+
});
|
|
86
|
+
const state = getPondAccountState(accountId);
|
|
87
|
+
if (state) state.activeRunId = run.id;
|
|
88
|
+
await client.createAgentStep(config.org_id, agentUserId, run.id, {
|
|
89
|
+
step_type: "thinking",
|
|
90
|
+
content: { text: "" },
|
|
91
|
+
});
|
|
92
|
+
return run.id;
|
|
93
|
+
} catch (err) {
|
|
94
|
+
log?.warn(`pond[${accountId}]: failed to create agent run: ${String(err)}`);
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
})();
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
} catch (err) {
|
|
102
|
+
log?.error(`pond[${accountId}]: dispatch failed for ${messageId}: ${String(err)}`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
38
106
|
export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
|
|
39
107
|
startAccount: async (ctx) => {
|
|
40
108
|
const account = resolvePondAccount({ cfg: ctx.cfg, accountId: ctx.accountId });
|
|
@@ -55,6 +123,30 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
|
|
|
55
123
|
const agentUserId = me.id;
|
|
56
124
|
log?.info(`pond[${ctx.accountId}]: authenticated as ${me.display_name} (${agentUserId})`);
|
|
57
125
|
|
|
126
|
+
// Inject env vars so child processes (e.g. @pnds/cli) inherit Pond credentials
|
|
127
|
+
process.env.POND_API_URL = config.pond_url;
|
|
128
|
+
process.env.POND_API_KEY = config.api_key;
|
|
129
|
+
process.env.POND_ORG_ID = config.org_id;
|
|
130
|
+
|
|
131
|
+
// Apply platform config (models, defaults)
|
|
132
|
+
// Use OPENCLAW_STATE_DIR if set, otherwise derive from HOME (systemd sets HOME=/data for hosted agents)
|
|
133
|
+
const stateDir = process.env.OPENCLAW_STATE_DIR
|
|
134
|
+
|| path.join(process.env.HOME || "/data", ".openclaw");
|
|
135
|
+
const openclawConfigPath = path.join(stateDir, "openclaw.json");
|
|
136
|
+
const configManagerOpts = {
|
|
137
|
+
client,
|
|
138
|
+
stateDir,
|
|
139
|
+
configPath: openclawConfigPath,
|
|
140
|
+
credentials: { api_key: config.api_key, pond_url: config.pond_url },
|
|
141
|
+
log,
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
try {
|
|
145
|
+
await fetchAndApplyPlatformConfig(configManagerOpts);
|
|
146
|
+
} catch (err) {
|
|
147
|
+
log?.warn(`pond[${ctx.accountId}]: platform config fetch failed: ${String(err)}`);
|
|
148
|
+
}
|
|
149
|
+
|
|
58
150
|
// Connect WebSocket via ticket auth (reconnection enabled by default in PondWs)
|
|
59
151
|
const wsUrl = resolveWsUrl(account);
|
|
60
152
|
const ws = new PondWs({
|
|
@@ -67,15 +159,18 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
|
|
|
67
159
|
|
|
68
160
|
// Session ID from hello — used for heartbeat
|
|
69
161
|
let sessionId = "";
|
|
162
|
+
// Heartbeat interval handle — created inside hello handler with server-provided interval
|
|
163
|
+
let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
|
70
164
|
|
|
71
165
|
// Log connection state changes (covers reconnection)
|
|
72
166
|
ws.onStateChange((state) => {
|
|
73
167
|
log?.info(`pond[${ctx.accountId}]: connection state → ${state}`);
|
|
74
168
|
});
|
|
75
169
|
|
|
76
|
-
// On hello, cache chat types
|
|
170
|
+
// On hello, cache chat types and start heartbeat with server-provided interval
|
|
77
171
|
ws.on("hello", async (data: HelloData) => {
|
|
78
172
|
sessionId = data.session_id ?? "";
|
|
173
|
+
const intervalSec = data.heartbeat_interval > 0 ? data.heartbeat_interval : 30;
|
|
79
174
|
try {
|
|
80
175
|
const count = await fetchAllChats(client, config.org_id, chatTypeMap);
|
|
81
176
|
log?.info(`pond[${ctx.accountId}]: WebSocket connected, ${count} chats cached`);
|
|
@@ -86,6 +181,19 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
|
|
|
86
181
|
orgId: config.org_id,
|
|
87
182
|
agentUserId,
|
|
88
183
|
});
|
|
184
|
+
|
|
185
|
+
// (Re)start heartbeat with server-provided interval
|
|
186
|
+
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
|
187
|
+
heartbeatTimer = setInterval(() => {
|
|
188
|
+
if (ws.state !== "connected") return;
|
|
189
|
+
ws.sendAgentHeartbeat(sessionId, {
|
|
190
|
+
hostname: os.hostname(),
|
|
191
|
+
cores: os.cpus().length,
|
|
192
|
+
mem_total: os.totalmem(),
|
|
193
|
+
mem_free: os.freemem(),
|
|
194
|
+
uptime: os.uptime(),
|
|
195
|
+
});
|
|
196
|
+
}, intervalSec * 1000);
|
|
89
197
|
} catch (err) {
|
|
90
198
|
log?.error(`pond[${ctx.accountId}]: failed to fetch chats: ${String(err)}`);
|
|
91
199
|
}
|
|
@@ -98,14 +206,10 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
|
|
|
98
206
|
}
|
|
99
207
|
});
|
|
100
208
|
|
|
101
|
-
// Handle inbound messages
|
|
209
|
+
// Handle inbound messages (text + file)
|
|
102
210
|
ws.on("message.new", async (data: MessageNewData) => {
|
|
103
211
|
if (data.sender_id === agentUserId) return;
|
|
104
|
-
if (data.message_type !== "text") return;
|
|
105
|
-
|
|
106
|
-
const content = data.content as TextContent;
|
|
107
|
-
const text = content.text?.trim();
|
|
108
|
-
if (!text) return;
|
|
212
|
+
if (data.message_type !== "text" && data.message_type !== "file") return;
|
|
109
213
|
|
|
110
214
|
const chatId = data.chat_id;
|
|
111
215
|
|
|
@@ -122,13 +226,46 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
|
|
|
122
226
|
}
|
|
123
227
|
}
|
|
124
228
|
|
|
125
|
-
//
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
229
|
+
// Build body text and optional media fields based on message type
|
|
230
|
+
let body = "";
|
|
231
|
+
let mediaFields: Record<string, string | undefined> = {};
|
|
232
|
+
|
|
233
|
+
if (data.message_type === "text") {
|
|
234
|
+
const content = data.content as TextContent;
|
|
235
|
+
body = content.text?.trim() ?? "";
|
|
236
|
+
if (!body) return;
|
|
237
|
+
|
|
238
|
+
// In group chats, only respond when @mentioned or @all
|
|
239
|
+
if (chatType === "group") {
|
|
240
|
+
const mentions = content.mentions ?? [];
|
|
241
|
+
const isMentioned = mentions.some(
|
|
242
|
+
(m) => m.user_id === agentUserId || m.user_id === MENTION_ALL_USER_ID
|
|
243
|
+
);
|
|
244
|
+
if (!isMentioned) return;
|
|
245
|
+
}
|
|
246
|
+
} else {
|
|
247
|
+
// file message
|
|
248
|
+
const content = data.content as MediaContent;
|
|
249
|
+
|
|
250
|
+
// In group chats, file messages have no mentions field — skip
|
|
251
|
+
if (chatType === "group") return;
|
|
252
|
+
|
|
253
|
+
body = content.caption?.trim()
|
|
254
|
+
|| `[file: ${content.file_name || "attachment"}]`;
|
|
255
|
+
|
|
256
|
+
// Resolve presigned download URL for the attachment
|
|
257
|
+
if (content.attachment_id) {
|
|
258
|
+
try {
|
|
259
|
+
const fileRes = await client.getFileUrl(content.attachment_id);
|
|
260
|
+
mediaFields = {
|
|
261
|
+
MediaUrl: fileRes.url,
|
|
262
|
+
MediaType: content.mime_type,
|
|
263
|
+
};
|
|
264
|
+
} catch (err) {
|
|
265
|
+
log?.warn(`pond[${ctx.accountId}]: failed to get file URL for ${content.attachment_id}: ${String(err)}`);
|
|
266
|
+
// Degrade gracefully — agent still gets the body text
|
|
267
|
+
}
|
|
268
|
+
}
|
|
132
269
|
}
|
|
133
270
|
|
|
134
271
|
const sessionKey = buildSessionKey(ctx.accountId, chatType, chatId);
|
|
@@ -136,10 +273,11 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
|
|
|
136
273
|
|
|
137
274
|
// Build inbound context for OpenClaw agent
|
|
138
275
|
const inboundCtx = core.channel.reply.finalizeInboundContext({
|
|
139
|
-
Body:
|
|
140
|
-
BodyForAgent:
|
|
141
|
-
RawBody:
|
|
142
|
-
CommandBody:
|
|
276
|
+
Body: body,
|
|
277
|
+
BodyForAgent: body,
|
|
278
|
+
RawBody: body,
|
|
279
|
+
CommandBody: body,
|
|
280
|
+
...mediaFields,
|
|
143
281
|
From: `pond:${data.sender_id}`,
|
|
144
282
|
To: `pond:${chatId}`,
|
|
145
283
|
SessionKey: sessionKey,
|
|
@@ -156,60 +294,42 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
|
|
|
156
294
|
OriginatingTo: `pond:${chatId}`,
|
|
157
295
|
});
|
|
158
296
|
|
|
159
|
-
|
|
297
|
+
await dispatchToAgent({
|
|
298
|
+
core,
|
|
299
|
+
cfg: ctx.cfg,
|
|
300
|
+
client,
|
|
301
|
+
config,
|
|
302
|
+
agentUserId,
|
|
303
|
+
accountId: ctx.accountId,
|
|
304
|
+
chatId,
|
|
305
|
+
messageId: data.id,
|
|
306
|
+
inboundCtx,
|
|
307
|
+
log,
|
|
308
|
+
});
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
// Re-apply platform config when server pushes an update notification
|
|
312
|
+
ws.on("agent_config.update", async (data: AgentConfigUpdateData) => {
|
|
313
|
+
log?.info(`pond[${ctx.accountId}]: config update notification (version=${data.version})`);
|
|
160
314
|
try {
|
|
161
|
-
await
|
|
162
|
-
ctx: inboundCtx,
|
|
163
|
-
cfg: ctx.cfg,
|
|
164
|
-
dispatcherOptions: {
|
|
165
|
-
deliver: async (payload) => {
|
|
166
|
-
const replyText = payload.text?.trim();
|
|
167
|
-
if (!replyText) return;
|
|
168
|
-
await client.sendMessage(config.org_id, chatId, {
|
|
169
|
-
message_type: "text",
|
|
170
|
-
content: { text: replyText },
|
|
171
|
-
});
|
|
172
|
-
},
|
|
173
|
-
onReplyStart: () => {
|
|
174
|
-
if (thinkingSent) return;
|
|
175
|
-
thinkingSent = true;
|
|
176
|
-
// Send "thinking" state delta (fire-and-forget)
|
|
177
|
-
client.sendMessage(config.org_id, chatId, {
|
|
178
|
-
message_type: "state_delta",
|
|
179
|
-
content: { state: "thinking", text: "", progress: 0, collapsible: true },
|
|
180
|
-
} as SendMessageRequest).catch((err) => {
|
|
181
|
-
log?.warn(`pond[${ctx.accountId}]: failed to send thinking state: ${String(err)}`);
|
|
182
|
-
});
|
|
183
|
-
},
|
|
184
|
-
},
|
|
185
|
-
});
|
|
315
|
+
await fetchAndApplyPlatformConfig(configManagerOpts);
|
|
186
316
|
} catch (err) {
|
|
187
|
-
log?.
|
|
317
|
+
log?.warn(`pond[${ctx.accountId}]: config update failed: ${String(err)}`);
|
|
188
318
|
}
|
|
189
319
|
});
|
|
190
320
|
|
|
191
321
|
log?.info(`pond[${ctx.accountId}]: connecting to ${wsUrl}...`);
|
|
192
322
|
await ws.connect();
|
|
193
323
|
|
|
194
|
-
// Start periodic heartbeat with system telemetry
|
|
195
|
-
const heartbeatInterval = setInterval(() => {
|
|
196
|
-
if (ws.state !== "connected") return;
|
|
197
|
-
const totalMem = os.totalmem();
|
|
198
|
-
const freeMem = os.freemem();
|
|
199
|
-
ws.sendAgentHeartbeat(sessionId, {
|
|
200
|
-
hostname: os.hostname(),
|
|
201
|
-
cores: os.cpus().length,
|
|
202
|
-
mem_total: totalMem,
|
|
203
|
-
mem_free: freeMem,
|
|
204
|
-
uptime: os.uptime(),
|
|
205
|
-
});
|
|
206
|
-
}, 30_000); // every 30s
|
|
207
|
-
|
|
208
324
|
// Clean up on abort
|
|
209
325
|
ctx.abortSignal.addEventListener("abort", () => {
|
|
210
|
-
clearInterval(
|
|
326
|
+
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
|
211
327
|
ws.disconnect();
|
|
212
328
|
removePondAccountState(ctx.accountId);
|
|
329
|
+
// Clear injected env vars so stale credentials don't leak to future subprocesses
|
|
330
|
+
delete process.env.POND_API_URL;
|
|
331
|
+
delete process.env.POND_API_KEY;
|
|
332
|
+
delete process.env.POND_ORG_ID;
|
|
213
333
|
log?.info(`pond[${ctx.accountId}]: disconnected`);
|
|
214
334
|
});
|
|
215
335
|
|
package/src/hooks.ts
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
2
|
-
import type { SendMessageRequest } from "@pnds/sdk";
|
|
3
2
|
import { extractAccountIdFromSessionKey } from "./session.js";
|
|
4
3
|
import { getPondAccountState, getSessionChatId } from "./runtime.js";
|
|
5
4
|
|
|
6
5
|
/**
|
|
7
|
-
* Resolve the PondClient + orgId for a hook context.
|
|
6
|
+
* Resolve the PondClient + orgId + runId for a hook context.
|
|
8
7
|
* Matches by accountId extracted from the session key.
|
|
9
8
|
*/
|
|
10
9
|
function resolveClientForHook(sessionKey: string | undefined) {
|
|
@@ -21,36 +20,59 @@ function resolveClientForHook(sessionKey: string | undefined) {
|
|
|
21
20
|
return { ...state, chatId };
|
|
22
21
|
}
|
|
23
22
|
|
|
23
|
+
const POND_CLI_CONTEXT = `## Pond CLI
|
|
24
|
+
|
|
25
|
+
You have access to the Pond platform via the \`@pnds/cli\` CLI. Auth is pre-configured — just run commands directly.
|
|
26
|
+
|
|
27
|
+
\`\`\`
|
|
28
|
+
npx @pnds/cli@latest whoami # Check your identity
|
|
29
|
+
npx @pnds/cli@latest chats list # List chats
|
|
30
|
+
npx @pnds/cli@latest messages list <chatId> # Read chat history
|
|
31
|
+
npx @pnds/cli@latest messages send <chatId> --text "..." # Send a message
|
|
32
|
+
npx @pnds/cli@latest tasks list [--status ...] # List tasks
|
|
33
|
+
npx @pnds/cli@latest tasks create --title "..." # Create a task
|
|
34
|
+
npx @pnds/cli@latest tasks update <taskId> --status in_progress
|
|
35
|
+
npx @pnds/cli@latest projects list # List projects
|
|
36
|
+
npx @pnds/cli@latest users search <query> # Find users
|
|
37
|
+
npx @pnds/cli@latest members list # List org members
|
|
38
|
+
\`\`\`
|
|
39
|
+
|
|
40
|
+
Run \`npx @pnds/cli@latest --help\` or \`npx @pnds/cli@latest <command> --help\` for full options. Output is JSON.`;
|
|
41
|
+
|
|
24
42
|
export function registerPondHooks(api: OpenClawPluginApi) {
|
|
25
43
|
const log = api.logger;
|
|
26
44
|
|
|
27
|
-
//
|
|
45
|
+
// Inject CLI awareness into agent system prompt
|
|
46
|
+
api.on("before_prompt_build", () => {
|
|
47
|
+
return { prependContext: POND_CLI_CONTEXT };
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// before_tool_call -> send tool_call step to AgentRun
|
|
28
51
|
api.on("before_tool_call", async (event, ctx) => {
|
|
29
52
|
const resolved = resolveClientForHook(ctx.sessionKey);
|
|
30
|
-
if (!resolved) return;
|
|
53
|
+
if (!resolved || !resolved.activeRunId) return;
|
|
31
54
|
try {
|
|
32
|
-
|
|
33
|
-
|
|
55
|
+
await resolved.client.createAgentStep(resolved.orgId, resolved.agentUserId, resolved.activeRunId, {
|
|
56
|
+
step_type: "tool_call",
|
|
34
57
|
content: {
|
|
35
58
|
tool_name: event.toolName,
|
|
36
59
|
tool_input: event.params ?? {},
|
|
37
60
|
status: "running",
|
|
38
61
|
started_at: new Date().toISOString(),
|
|
39
62
|
},
|
|
40
|
-
};
|
|
41
|
-
await resolved.client.sendMessage(resolved.orgId, resolved.chatId, req);
|
|
63
|
+
});
|
|
42
64
|
} catch (err) {
|
|
43
65
|
log?.warn(`pond hook before_tool_call failed: ${String(err)}`);
|
|
44
66
|
}
|
|
45
67
|
});
|
|
46
68
|
|
|
47
|
-
// after_tool_call -> send tool_result
|
|
69
|
+
// after_tool_call -> send tool_result step to AgentRun
|
|
48
70
|
api.on("after_tool_call", async (event, ctx) => {
|
|
49
71
|
const resolved = resolveClientForHook(ctx.sessionKey);
|
|
50
|
-
if (!resolved) return;
|
|
72
|
+
if (!resolved || !resolved.activeRunId) return;
|
|
51
73
|
try {
|
|
52
|
-
|
|
53
|
-
|
|
74
|
+
await resolved.client.createAgentStep(resolved.orgId, resolved.agentUserId, resolved.activeRunId, {
|
|
75
|
+
step_type: "tool_result",
|
|
54
76
|
content: {
|
|
55
77
|
tool_call_id: event.toolCallId ?? "",
|
|
56
78
|
tool_name: event.toolName,
|
|
@@ -59,31 +81,29 @@ export function registerPondHooks(api: OpenClawPluginApi) {
|
|
|
59
81
|
duration_ms: event.durationMs ?? 0,
|
|
60
82
|
collapsible: true,
|
|
61
83
|
},
|
|
62
|
-
};
|
|
63
|
-
await resolved.client.sendMessage(resolved.orgId, resolved.chatId, req);
|
|
84
|
+
});
|
|
64
85
|
} catch (err) {
|
|
65
86
|
log?.warn(`pond hook after_tool_call failed: ${String(err)}`);
|
|
66
87
|
}
|
|
67
88
|
});
|
|
68
89
|
|
|
69
|
-
// agent_end ->
|
|
90
|
+
// agent_end -> complete the AgentRun
|
|
70
91
|
api.on("agent_end", async (_event, ctx) => {
|
|
71
92
|
const resolved = resolveClientForHook(ctx.sessionKey);
|
|
72
93
|
if (!resolved) {
|
|
73
94
|
log?.warn(`pond hook agent_end: could not resolve client (sessionKey=${ctx.sessionKey})`);
|
|
74
95
|
return;
|
|
75
96
|
}
|
|
97
|
+
if (!resolved.activeRunId) return;
|
|
76
98
|
try {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
};
|
|
86
|
-
await resolved.client.sendMessage(resolved.orgId, resolved.chatId, req);
|
|
99
|
+
await resolved.client.updateAgentRun(resolved.orgId, resolved.agentUserId, resolved.activeRunId, {
|
|
100
|
+
status: "completed",
|
|
101
|
+
});
|
|
102
|
+
// Clear the active run
|
|
103
|
+
const state = getPondAccountState(
|
|
104
|
+
extractAccountIdFromSessionKey(ctx.sessionKey ?? "") ?? "",
|
|
105
|
+
);
|
|
106
|
+
if (state) state.activeRunId = undefined;
|
|
87
107
|
} catch (err) {
|
|
88
108
|
log?.warn(`pond hook agent_end failed: ${String(err)}`);
|
|
89
109
|
}
|
package/src/outbound.ts
CHANGED
|
@@ -25,6 +25,7 @@ export const pondOutbound: ChannelOutboundAdapter = {
|
|
|
25
25
|
const req: SendMessageRequest = {
|
|
26
26
|
message_type: "text",
|
|
27
27
|
content: { text },
|
|
28
|
+
agent_run_id: state?.activeRunId,
|
|
28
29
|
};
|
|
29
30
|
const msg = await client.sendMessage(orgId, chatId, req);
|
|
30
31
|
return { channel: "pond", messageId: msg.id, channelId: chatId };
|
package/src/runtime.ts
CHANGED