@pnds/pond 0.1.1 → 0.2.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 +1 -1
- package/src/channel.ts +1 -1
- package/src/config-manager.ts +150 -0
- package/src/gateway.ts +177 -63
- package/src/hooks.ts +45 -25
- package/src/runtime.ts +1 -0
package/package.json
CHANGED
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,65 @@ 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
|
+
try {
|
|
60
|
+
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
61
|
+
ctx: inboundCtx,
|
|
62
|
+
cfg,
|
|
63
|
+
dispatcherOptions: {
|
|
64
|
+
deliver: async (payload: { text?: string }) => {
|
|
65
|
+
const replyText = payload.text?.trim();
|
|
66
|
+
if (!replyText) return;
|
|
67
|
+
await client.sendMessage(config.org_id, chatId, {
|
|
68
|
+
message_type: "text",
|
|
69
|
+
content: { text: replyText },
|
|
70
|
+
});
|
|
71
|
+
},
|
|
72
|
+
onReplyStart: () => {
|
|
73
|
+
if (thinkingSent) return;
|
|
74
|
+
thinkingSent = true;
|
|
75
|
+
(async () => {
|
|
76
|
+
try {
|
|
77
|
+
const run = await client.createAgentRun(config.org_id, agentUserId, {
|
|
78
|
+
trigger_type: "mention",
|
|
79
|
+
trigger_ref: { message_id: messageId },
|
|
80
|
+
chat_id: chatId,
|
|
81
|
+
});
|
|
82
|
+
const state = getPondAccountState(accountId);
|
|
83
|
+
if (state) state.activeRunId = run.id;
|
|
84
|
+
await client.createAgentStep(config.org_id, agentUserId, run.id, {
|
|
85
|
+
step_type: "thinking",
|
|
86
|
+
content: { text: "" },
|
|
87
|
+
});
|
|
88
|
+
} catch (err) {
|
|
89
|
+
log?.warn(`pond[${accountId}]: failed to create agent run: ${String(err)}`);
|
|
90
|
+
}
|
|
91
|
+
})();
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
} catch (err) {
|
|
96
|
+
log?.error(`pond[${accountId}]: dispatch failed for ${messageId}: ${String(err)}`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
38
100
|
export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
|
|
39
101
|
startAccount: async (ctx) => {
|
|
40
102
|
const account = resolvePondAccount({ cfg: ctx.cfg, accountId: ctx.accountId });
|
|
@@ -55,6 +117,30 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
|
|
|
55
117
|
const agentUserId = me.id;
|
|
56
118
|
log?.info(`pond[${ctx.accountId}]: authenticated as ${me.display_name} (${agentUserId})`);
|
|
57
119
|
|
|
120
|
+
// Inject env vars so child processes (e.g. @pnds/cli) inherit Pond credentials
|
|
121
|
+
process.env.POND_API_URL = config.pond_url;
|
|
122
|
+
process.env.POND_API_KEY = config.api_key;
|
|
123
|
+
process.env.POND_ORG_ID = config.org_id;
|
|
124
|
+
|
|
125
|
+
// Apply platform config (models, defaults)
|
|
126
|
+
// Use OPENCLAW_STATE_DIR if set, otherwise derive from HOME (systemd sets HOME=/data for hosted agents)
|
|
127
|
+
const stateDir = process.env.OPENCLAW_STATE_DIR
|
|
128
|
+
|| path.join(process.env.HOME || "/data", ".openclaw");
|
|
129
|
+
const openclawConfigPath = path.join(stateDir, "openclaw.json");
|
|
130
|
+
const configManagerOpts = {
|
|
131
|
+
client,
|
|
132
|
+
stateDir,
|
|
133
|
+
configPath: openclawConfigPath,
|
|
134
|
+
credentials: { api_key: config.api_key, pond_url: config.pond_url },
|
|
135
|
+
log,
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
try {
|
|
139
|
+
await fetchAndApplyPlatformConfig(configManagerOpts);
|
|
140
|
+
} catch (err) {
|
|
141
|
+
log?.warn(`pond[${ctx.accountId}]: platform config fetch failed: ${String(err)}`);
|
|
142
|
+
}
|
|
143
|
+
|
|
58
144
|
// Connect WebSocket via ticket auth (reconnection enabled by default in PondWs)
|
|
59
145
|
const wsUrl = resolveWsUrl(account);
|
|
60
146
|
const ws = new PondWs({
|
|
@@ -67,15 +153,18 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
|
|
|
67
153
|
|
|
68
154
|
// Session ID from hello — used for heartbeat
|
|
69
155
|
let sessionId = "";
|
|
156
|
+
// Heartbeat interval handle — created inside hello handler with server-provided interval
|
|
157
|
+
let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
|
70
158
|
|
|
71
159
|
// Log connection state changes (covers reconnection)
|
|
72
160
|
ws.onStateChange((state) => {
|
|
73
161
|
log?.info(`pond[${ctx.accountId}]: connection state → ${state}`);
|
|
74
162
|
});
|
|
75
163
|
|
|
76
|
-
// On hello, cache chat types
|
|
164
|
+
// On hello, cache chat types and start heartbeat with server-provided interval
|
|
77
165
|
ws.on("hello", async (data: HelloData) => {
|
|
78
166
|
sessionId = data.session_id ?? "";
|
|
167
|
+
const intervalSec = data.heartbeat_interval > 0 ? data.heartbeat_interval : 30;
|
|
79
168
|
try {
|
|
80
169
|
const count = await fetchAllChats(client, config.org_id, chatTypeMap);
|
|
81
170
|
log?.info(`pond[${ctx.accountId}]: WebSocket connected, ${count} chats cached`);
|
|
@@ -86,6 +175,19 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
|
|
|
86
175
|
orgId: config.org_id,
|
|
87
176
|
agentUserId,
|
|
88
177
|
});
|
|
178
|
+
|
|
179
|
+
// (Re)start heartbeat with server-provided interval
|
|
180
|
+
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
|
181
|
+
heartbeatTimer = setInterval(() => {
|
|
182
|
+
if (ws.state !== "connected") return;
|
|
183
|
+
ws.sendAgentHeartbeat(sessionId, {
|
|
184
|
+
hostname: os.hostname(),
|
|
185
|
+
cores: os.cpus().length,
|
|
186
|
+
mem_total: os.totalmem(),
|
|
187
|
+
mem_free: os.freemem(),
|
|
188
|
+
uptime: os.uptime(),
|
|
189
|
+
});
|
|
190
|
+
}, intervalSec * 1000);
|
|
89
191
|
} catch (err) {
|
|
90
192
|
log?.error(`pond[${ctx.accountId}]: failed to fetch chats: ${String(err)}`);
|
|
91
193
|
}
|
|
@@ -98,14 +200,10 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
|
|
|
98
200
|
}
|
|
99
201
|
});
|
|
100
202
|
|
|
101
|
-
// Handle inbound messages
|
|
203
|
+
// Handle inbound messages (text + file)
|
|
102
204
|
ws.on("message.new", async (data: MessageNewData) => {
|
|
103
205
|
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;
|
|
206
|
+
if (data.message_type !== "text" && data.message_type !== "file") return;
|
|
109
207
|
|
|
110
208
|
const chatId = data.chat_id;
|
|
111
209
|
|
|
@@ -122,13 +220,46 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
|
|
|
122
220
|
}
|
|
123
221
|
}
|
|
124
222
|
|
|
125
|
-
//
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
223
|
+
// Build body text and optional media fields based on message type
|
|
224
|
+
let body = "";
|
|
225
|
+
let mediaFields: Record<string, string | undefined> = {};
|
|
226
|
+
|
|
227
|
+
if (data.message_type === "text") {
|
|
228
|
+
const content = data.content as TextContent;
|
|
229
|
+
body = content.text?.trim() ?? "";
|
|
230
|
+
if (!body) return;
|
|
231
|
+
|
|
232
|
+
// In group chats, only respond when @mentioned or @all
|
|
233
|
+
if (chatType === "group") {
|
|
234
|
+
const mentions = content.mentions ?? [];
|
|
235
|
+
const isMentioned = mentions.some(
|
|
236
|
+
(m) => m.user_id === agentUserId || m.user_id === MENTION_ALL_USER_ID
|
|
237
|
+
);
|
|
238
|
+
if (!isMentioned) return;
|
|
239
|
+
}
|
|
240
|
+
} else {
|
|
241
|
+
// file message
|
|
242
|
+
const content = data.content as MediaContent;
|
|
243
|
+
|
|
244
|
+
// In group chats, file messages have no mentions field — skip
|
|
245
|
+
if (chatType === "group") return;
|
|
246
|
+
|
|
247
|
+
body = content.caption?.trim()
|
|
248
|
+
|| `[file: ${content.file_name || "attachment"}]`;
|
|
249
|
+
|
|
250
|
+
// Resolve presigned download URL for the attachment
|
|
251
|
+
if (content.attachment_id) {
|
|
252
|
+
try {
|
|
253
|
+
const fileRes = await client.getFileUrl(content.attachment_id);
|
|
254
|
+
mediaFields = {
|
|
255
|
+
MediaUrl: fileRes.url,
|
|
256
|
+
MediaType: content.mime_type,
|
|
257
|
+
};
|
|
258
|
+
} catch (err) {
|
|
259
|
+
log?.warn(`pond[${ctx.accountId}]: failed to get file URL for ${content.attachment_id}: ${String(err)}`);
|
|
260
|
+
// Degrade gracefully — agent still gets the body text
|
|
261
|
+
}
|
|
262
|
+
}
|
|
132
263
|
}
|
|
133
264
|
|
|
134
265
|
const sessionKey = buildSessionKey(ctx.accountId, chatType, chatId);
|
|
@@ -136,10 +267,11 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
|
|
|
136
267
|
|
|
137
268
|
// Build inbound context for OpenClaw agent
|
|
138
269
|
const inboundCtx = core.channel.reply.finalizeInboundContext({
|
|
139
|
-
Body:
|
|
140
|
-
BodyForAgent:
|
|
141
|
-
RawBody:
|
|
142
|
-
CommandBody:
|
|
270
|
+
Body: body,
|
|
271
|
+
BodyForAgent: body,
|
|
272
|
+
RawBody: body,
|
|
273
|
+
CommandBody: body,
|
|
274
|
+
...mediaFields,
|
|
143
275
|
From: `pond:${data.sender_id}`,
|
|
144
276
|
To: `pond:${chatId}`,
|
|
145
277
|
SessionKey: sessionKey,
|
|
@@ -156,60 +288,42 @@ export const pondGateway: ChannelGatewayAdapter<ResolvedPondAccount> = {
|
|
|
156
288
|
OriginatingTo: `pond:${chatId}`,
|
|
157
289
|
});
|
|
158
290
|
|
|
159
|
-
|
|
291
|
+
await dispatchToAgent({
|
|
292
|
+
core,
|
|
293
|
+
cfg: ctx.cfg,
|
|
294
|
+
client,
|
|
295
|
+
config,
|
|
296
|
+
agentUserId,
|
|
297
|
+
accountId: ctx.accountId,
|
|
298
|
+
chatId,
|
|
299
|
+
messageId: data.id,
|
|
300
|
+
inboundCtx,
|
|
301
|
+
log,
|
|
302
|
+
});
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
// Re-apply platform config when server pushes an update notification
|
|
306
|
+
ws.on("agent_config.update", async (data: AgentConfigUpdateData) => {
|
|
307
|
+
log?.info(`pond[${ctx.accountId}]: config update notification (version=${data.version})`);
|
|
160
308
|
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
|
-
});
|
|
309
|
+
await fetchAndApplyPlatformConfig(configManagerOpts);
|
|
186
310
|
} catch (err) {
|
|
187
|
-
log?.
|
|
311
|
+
log?.warn(`pond[${ctx.accountId}]: config update failed: ${String(err)}`);
|
|
188
312
|
}
|
|
189
313
|
});
|
|
190
314
|
|
|
191
315
|
log?.info(`pond[${ctx.accountId}]: connecting to ${wsUrl}...`);
|
|
192
316
|
await ws.connect();
|
|
193
317
|
|
|
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
318
|
// Clean up on abort
|
|
209
319
|
ctx.abortSignal.addEventListener("abort", () => {
|
|
210
|
-
clearInterval(
|
|
320
|
+
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
|
211
321
|
ws.disconnect();
|
|
212
322
|
removePondAccountState(ctx.accountId);
|
|
323
|
+
// Clear injected env vars so stale credentials don't leak to future subprocesses
|
|
324
|
+
delete process.env.POND_API_URL;
|
|
325
|
+
delete process.env.POND_API_KEY;
|
|
326
|
+
delete process.env.POND_ORG_ID;
|
|
213
327
|
log?.info(`pond[${ctx.accountId}]: disconnected`);
|
|
214
328
|
});
|
|
215
329
|
|
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/runtime.ts
CHANGED