@pnds/pond 1.1.0 → 1.2.1

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/src/hooks.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
2
2
  import { extractAccountIdFromSessionKey } from "./session.js";
3
- import { getActiveRunId, getPondAccountState, getSessionChatId } from "./runtime.js";
3
+ import { clearDispatchMessageId, clearSessionMessageId, getActiveRunId, getPondAccountState, getSessionChatId } from "./runtime.js";
4
+ import { loadToolsMarkdown } from "./tools-md.js";
4
5
 
5
6
  /**
6
7
  * Resolve the PondClient + orgId + runId for a hook context.
@@ -24,42 +25,73 @@ async function resolveClientForHook(sessionKey: string | undefined) {
24
25
  return { ...state, chatId, activeRunId };
25
26
  }
26
27
 
27
- const POND_CLI_CONTEXT = `## Pond CLI
28
+ const ORCHESTRATOR_PREFIX = `## CRITICAL — Pond Orchestrator Mode
28
29
 
29
- You have access to the Pond platform via the \`@pnds/cli\` CLI. Auth is pre-configured — just run commands directly.
30
+ > **Your text output is completely invisible.** No user or agent will ever see
31
+ > anything you write as plain text. The ONLY way to communicate is through
32
+ > Pond action tools. If you respond with text alone, your response is silently
33
+ > discarded.
30
34
 
31
- \`\`\`
32
- npx @pnds/cli@latest whoami # Check your identity
33
- npx @pnds/cli@latest agents list # List all agents (name, model, status)
34
- npx @pnds/cli@latest dm <name-or-id> --text "..." # DM a user by name or ID (auto-creates chat)
35
- npx @pnds/cli@latest dm <name-or-id> --text "..." --no-reply # DM, signal no reply expected
36
- npx @pnds/cli@latest chats list # List chats
37
- npx @pnds/cli@latest messages list <chatId> # Read chat history
38
- npx @pnds/cli@latest messages send <chatId> --text "..." # Send a message
39
- npx @pnds/cli@latest messages send <chatId> --text "..." --no-reply # Send, no reply expected
40
- npx @pnds/cli@latest tasks list [--status ...] # List tasks
41
- npx @pnds/cli@latest tasks create --title "..." # Create a task
42
- npx @pnds/cli@latest tasks update <taskId> --status in_progress
43
- npx @pnds/cli@latest projects list # List projects
44
- npx @pnds/cli@latest users search <query> # Find users
45
- npx @pnds/cli@latest members list # List org members
46
- \`\`\`
35
+ You are the Pond orchestrator. Events from multiple chats and tasks arrive as
36
+ structured \`[Event: ...]\` blocks. You decide how to handle each event and act
37
+ through tools.
47
38
 
48
- Run \`npx @pnds/cli@latest --help\` or \`npx @pnds/cli@latest <command> --help\` for full options. Output is JSON.
39
+ ### How to Reply to a Message
49
40
 
50
- ## Agent-to-Agent Interaction
41
+ When you receive \`[Event: message.new]\` with \`[Chat: ... (cht_abc123)]\`:
42
+
43
+ pond_reply(chatId: "cht_abc123", text: "Your response here")
44
+
45
+ If the event includes \`[Thread: 01JWC...]\`, reply inside the thread:
46
+
47
+ pond_reply(chatId: "cht_abc123", threadRootId: "01JWC...", text: "Thread reply here")
48
+
49
+ DO NOT write text. ONLY \`pond_reply\` delivers messages to users.
50
+
51
+ ### How to Act on a Task
52
+
53
+ When you receive \`[Event: task.assigned]\` with \`[Task: ... (tsk_xyz789)]\`:
54
+
55
+ - \`pond_task_comment(taskId: "tsk_xyz789", body: "Starting work...")\` — post progress
56
+ - \`pond_task_update(taskId: "tsk_xyz789", status: "in_progress")\` — change status
57
+
58
+ ### Pond Action Tools
59
+
60
+ - \`pond_reply\` — Send a text message to a chat. Params: \`chatId\`, \`text\`, optional \`threadRootId\`, \`noReply\`.
61
+ - \`pond_dm\` — Send a direct message to a user by ID (auto-creates DM chat). Params: \`userId\`, \`text\`, optional \`noReply\`. Use this for proactive outreach (e.g., notifying a task assigner of completion).
62
+ - \`pond_task_comment\` — Post a comment on a task. Params: \`taskId\`, \`body\`.
63
+ - \`pond_task_update\` — Update task fields (status, title, description, priority). Params: \`taskId\`, plus optional fields.
64
+ - \`pond_chat_history\` — Read recent messages from a chat (default 20, max 50). Params: \`chatId\`, optional \`limit\`, \`before\`, \`threadRootId\`.
65
+ - \`pond_typing\` — Start or stop the typing indicator. Params: \`chatId\`, \`action\` ("start" | "stop").
66
+
67
+ ### Sub-Agents
68
+
69
+ For long-running tasks, use \`sessions_spawn\` to delegate to a sub-agent. Return quickly so the orchestrator can process other events.
70
+
71
+ When a sub-agent completes and you receive a completion event, deliver the result to the relevant chat using \`pond_reply\` (or \`pond_dm\` if the target is a user, not a chat). Do NOT rely on text output — it is invisible.
72
+
73
+ ### Agent-to-Agent Interaction
51
74
 
52
75
  When you receive a message from another agent (not a human):
53
- - If the message has a no_reply hint, you may still reason and use tools, but your response will not be sent to chat.
54
- - If you have nothing meaningful to add to the conversation, produce an empty response to avoid unnecessary back-and-forth.
55
- - Use --no-reply when sending messages that don't require a response (e.g., delivering results, FYI notifications).`;
76
+ - If the message has a \`no_reply\` hint, do not call \`pond_reply\`. You may still reason and use other tools.
77
+ - If you have nothing meaningful to add, produce an empty response to avoid unnecessary back-and-forth.
78
+ - Use \`pond_reply\` with \`noReply: true\` when sending messages that don't need a response (FYI, results delivery).
79
+
80
+ ### Prohibitions
81
+
82
+ - **DO NOT** write text responses — they are invisible and silently discarded.
83
+ - **DO NOT** use the Pond CLI (\`@pnds/cli\`) to send messages — use \`pond_reply\` for chat replies and \`pond_dm\` for direct messages.
84
+ - **DO NOT** use the \`message\` tool to send to Pond chats — use \`pond_reply\`.`;
85
+
86
+ // Load wiki tools, CLI docs, and other tool docs from TOOLS.md (maintained separately)
87
+ const POND_ORCHESTRATOR_CONTEXT = ORCHESTRATOR_PREFIX + "\n\n" + loadToolsMarkdown();
56
88
 
57
89
  export function registerPondHooks(api: OpenClawPluginApi) {
58
90
  const log = api.logger;
59
91
 
60
92
  // Inject CLI awareness into agent system prompt
61
93
  api.on("before_prompt_build", () => {
62
- return { prependContext: POND_CLI_CONTEXT };
94
+ return { prependSystemContext: POND_ORCHESTRATOR_CONTEXT };
63
95
  });
64
96
 
65
97
  // before_tool_call -> send tool_call step to AgentRun
@@ -125,6 +157,10 @@ export function registerPondHooks(api: OpenClawPluginApi) {
125
157
  log?.warn(`pond hook agent_end failed: ${String(err)}`);
126
158
  } finally {
127
159
  // Always clear local state — even if the server call failed, the run is over locally
160
+ if (ctx.sessionKey) {
161
+ clearSessionMessageId(ctx.sessionKey);
162
+ clearDispatchMessageId(ctx.sessionKey);
163
+ }
128
164
  if (state && ctx.sessionKey) {
129
165
  state.activeRuns.delete(ctx.sessionKey.toLowerCase());
130
166
  }
package/src/index.ts CHANGED
@@ -1,8 +1,10 @@
1
1
  import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
2
- import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
2
+ import { emptyPluginConfigSchema } from "openclaw/plugin-sdk/core";
3
3
  import { pondPlugin } from "./channel.js";
4
4
  import { setPondRuntime } from "./runtime.js";
5
5
  import { registerPondHooks } from "./hooks.js";
6
+ import { registerPondWikiTools } from "./wiki-tools.js";
7
+ import { registerPondActionTools } from "./action-tools.js";
6
8
 
7
9
  const plugin = {
8
10
  id: "pond",
@@ -12,6 +14,8 @@ const plugin = {
12
14
  register(api: OpenClawPluginApi) {
13
15
  setPondRuntime(api.runtime);
14
16
  api.registerChannel({ plugin: pondPlugin });
17
+ registerPondWikiTools(api);
18
+ registerPondActionTools(api);
15
19
  registerPondHooks(api);
16
20
  },
17
21
  };
package/src/outbound.ts CHANGED
@@ -1,4 +1,7 @@
1
- import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk";
1
+ import type { ChannelPlugin } from "openclaw/plugin-sdk/core";
2
+
3
+ /** Extract ChannelOutboundAdapter from ChannelPlugin (removed from public SDK exports in 2026.3.24). */
4
+ type ChannelOutboundAdapter = NonNullable<ChannelPlugin["outbound"]>;
2
5
  import { resolvePondAccount } from "./accounts.js";
3
6
  import { getPondAccountState, getSessionChatId } from "./runtime.js";
4
7
  import { PondClient } from "@pnds/sdk";
package/src/routing.ts ADDED
@@ -0,0 +1,48 @@
1
+ import type { PondEvent, DispatchState } from "./runtime.js";
2
+
3
+ /** Where an inbound event should be routed. */
4
+ export type TriggerDisposition =
5
+ | { action: "main" }
6
+ | { action: "buffer-main" }
7
+ | { action: "buffer-fork"; forkKey: string }
8
+ | { action: "new-fork" };
9
+
10
+ /** Pluggable strategy for routing triggers when main session is busy. */
11
+ export type RoutingStrategy = (event: PondEvent, state: DispatchState) => TriggerDisposition;
12
+
13
+ const MAX_CONCURRENT_FORKS = 20;
14
+
15
+ /**
16
+ * Default routing strategy:
17
+ * - Same target as main → buffer-main (strict per-target serial ordering)
18
+ * - Active fork for same target → buffer into that fork
19
+ * - Too many forks → buffer-main (backpressure)
20
+ * - Otherwise → create a new fork
21
+ */
22
+ export const defaultRoutingStrategy: RoutingStrategy = (event, state) => {
23
+ // P0: same target as main must stay serial — fork would see stale transcript
24
+ if (state.mainCurrentTargetId === event.targetId) {
25
+ return { action: "buffer-main" };
26
+ }
27
+
28
+ // Same target already has a fork → buffer into it
29
+ const existingForkKey = state.activeForks.get(event.targetId);
30
+ if (existingForkKey) return { action: "buffer-fork", forkKey: existingForkKey };
31
+
32
+ // Backpressure: cap concurrent forks to avoid unbounded fan-out
33
+ if (state.activeForks.size >= MAX_CONCURRENT_FORKS) {
34
+ return { action: "buffer-main" };
35
+ }
36
+
37
+ return { action: "new-fork" };
38
+ };
39
+
40
+ /** Route an inbound event based on current dispatch state. */
41
+ export function routeTrigger(
42
+ event: PondEvent,
43
+ state: DispatchState,
44
+ strategy: RoutingStrategy = defaultRoutingStrategy,
45
+ ): TriggerDisposition {
46
+ if (!state.mainDispatching) return { action: "main" };
47
+ return strategy(event, state);
48
+ }
package/src/runtime.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { PluginRuntime } from "openclaw/plugin-sdk";
2
- import type { PondClient } from "@pnds/sdk";
2
+ import type { PondClient, PondWs } from "@pnds/sdk";
3
3
 
4
4
  let runtime: PluginRuntime | null = null;
5
5
 
@@ -20,6 +20,9 @@ export type PondAccountState = {
20
20
  orgId: string;
21
21
  agentUserId: string;
22
22
  activeRuns: Map<string, Promise<string | undefined>>; // sessionKey (lowercased) → runId promise
23
+ wikiMountRoot?: string;
24
+ ws?: PondWs;
25
+ orchestratorSessionKey?: string;
23
26
  };
24
27
 
25
28
  /** Resolve run ID for a session, awaiting if the run is still being created. */
@@ -52,6 +55,10 @@ export function getAllPondAccountStates(): ReadonlyMap<string, PondAccountState>
52
55
  // Session key → original chat ID mapping.
53
56
  // OpenClaw normalizes session keys to lowercase, but Pond IDs are case-sensitive.
54
57
  const sessionChatIdMap = new Map<string, string>();
58
+ const sessionMessageIdMap = new Map<string, string>();
59
+ // Per-dispatch snapshot: message ID captured when a dispatch starts, immune to later overwrites.
60
+ // Keyed by lowercased session key, cleared when the agent run ends.
61
+ const dispatchMessageIdMap = new Map<string, string>();
55
62
 
56
63
  export function setSessionChatId(sessionKey: string, chatId: string) {
57
64
  sessionChatIdMap.set(sessionKey.toLowerCase(), chatId);
@@ -60,3 +67,79 @@ export function setSessionChatId(sessionKey: string, chatId: string) {
60
67
  export function getSessionChatId(sessionKey: string): string | undefined {
61
68
  return sessionChatIdMap.get(sessionKey.toLowerCase());
62
69
  }
70
+
71
+ export function setSessionMessageId(sessionKey: string, messageId: string) {
72
+ sessionMessageIdMap.set(sessionKey.toLowerCase(), messageId);
73
+ }
74
+
75
+ export function getSessionMessageId(sessionKey: string): string | undefined {
76
+ return sessionMessageIdMap.get(sessionKey.toLowerCase());
77
+ }
78
+
79
+ export function clearSessionMessageId(sessionKey: string) {
80
+ sessionMessageIdMap.delete(sessionKey.toLowerCase());
81
+ }
82
+
83
+ /** Snapshot the message ID at dispatch time — immune to later session-level overwrites. */
84
+ export function setDispatchMessageId(sessionKey: string, messageId: string) {
85
+ dispatchMessageIdMap.set(sessionKey.toLowerCase(), messageId);
86
+ }
87
+
88
+ /** Read the dispatch-time snapshot. Prefer this over getSessionMessageId for provenance. */
89
+ export function getDispatchMessageId(sessionKey: string): string | undefined {
90
+ return dispatchMessageIdMap.get(sessionKey.toLowerCase());
91
+ }
92
+
93
+ export function clearDispatchMessageId(sessionKey: string) {
94
+ dispatchMessageIdMap.delete(sessionKey.toLowerCase());
95
+ }
96
+
97
+ // Per-dispatch noReply flag — deterministic suppression for agent-to-agent loop prevention.
98
+ // When true, pond_reply hard-blocks sends and records a suppressed AgentStep instead.
99
+ const dispatchNoReplyMap = new Map<string, boolean>();
100
+
101
+ export function setDispatchNoReply(sessionKey: string, noReply: boolean) {
102
+ dispatchNoReplyMap.set(sessionKey.toLowerCase(), noReply);
103
+ }
104
+
105
+ export function getDispatchNoReply(sessionKey: string): boolean {
106
+ return dispatchNoReplyMap.get(sessionKey.toLowerCase()) ?? false;
107
+ }
108
+
109
+ export function clearDispatchNoReply(sessionKey: string) {
110
+ dispatchNoReplyMap.delete(sessionKey.toLowerCase());
111
+ }
112
+
113
+ /** Result from a completed fork dispatch, to be injected into main session's next turn. */
114
+ export type ForkResult = {
115
+ forkSessionKey: string;
116
+ sourceEvent: { type: string; targetId: string; summary: string };
117
+ actions: string[]; // human-readable list of actions taken (e.g. "replied to cht_xxx")
118
+ agentRunId?: string;
119
+ };
120
+
121
+ /** Mutable dispatch state for the orchestrator gateway. Per-account, managed by gateway.ts. */
122
+ export type DispatchState = {
123
+ mainDispatching: boolean;
124
+ /** The targetId currently being processed by main. Used to enforce per-target serial ordering. */
125
+ mainCurrentTargetId?: string;
126
+ /** targetId → fork session key. One active fork per target at most. */
127
+ activeForks: Map<string, string>;
128
+ pendingForkResults: ForkResult[];
129
+ mainBuffer: PondEvent[];
130
+ };
131
+
132
+ /** Normalized inbound event from Pond. */
133
+ export type PondEvent = {
134
+ type: "message" | "task";
135
+ targetId: string; // chatId or taskId
136
+ targetName?: string;
137
+ targetType?: string; // "direct" | "group" | "task"
138
+ senderId: string;
139
+ senderName: string;
140
+ messageId: string;
141
+ body: string; // raw message text or task description
142
+ threadRootId?: string;
143
+ noReply?: boolean;
144
+ mediaFields?: Record<string, string | undefined>;
145
+ };
package/src/session.ts CHANGED
@@ -29,3 +29,8 @@ export function extractChatIdFromSessionKey(sessionKey: string | undefined): str
29
29
  if (parts.length < 3) return undefined;
30
30
  return parts.slice(2).join(":") || undefined;
31
31
  }
32
+
33
+ /** Build the single orchestrator session key for all Pond events. */
34
+ export function buildOrchestratorSessionKey(accountId: string): string {
35
+ return `${POND_SESSION_PREFIX}${accountId}:orchestrator`;
36
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Wrap a value as an agent tool result with JSON text content.
3
+ * Replaces the SDK's jsonResult which was removed from public subpath exports
4
+ * in OpenClaw 2026.3.24.
5
+ */
6
+ export function jsonResult(payload: unknown): { content: Array<{ type: "text"; text: string }>; details: unknown } {
7
+ return {
8
+ content: [{ type: "text" as const, text: JSON.stringify(payload, null, 2) }],
9
+ details: payload,
10
+ };
11
+ }
@@ -0,0 +1,7 @@
1
+ import fs from "node:fs";
2
+
3
+ const TOOLS_MD_PATH = new URL("../TOOLS.md", import.meta.url);
4
+
5
+ export function loadToolsMarkdown(): string {
6
+ return fs.readFileSync(TOOLS_MD_PATH, "utf8").trim();
7
+ }
@@ -0,0 +1,157 @@
1
+ import { spawn } from "node:child_process";
2
+ import path from "node:path";
3
+
4
+ type Logger = {
5
+ info?: (message: string) => void;
6
+ warn?: (message: string) => void;
7
+ error?: (message: string) => void;
8
+ };
9
+
10
+ type StartWikiHelperParams = {
11
+ accountId: string;
12
+ pondUrl: string;
13
+ apiKey: string;
14
+ orgId: string;
15
+ agentId: string;
16
+ stateDir: string;
17
+ log?: Logger;
18
+ };
19
+
20
+ type WikiHelperRuntime = {
21
+ mountRoot: string;
22
+ stop: () => void;
23
+ };
24
+
25
+ const DEFAULT_SYNC_TIMEOUT_MS = 30_000;
26
+ const DEFAULT_WATCH_INTERVAL_SEC = 30;
27
+
28
+ function isCommandMissing(error: unknown): boolean {
29
+ return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
30
+ }
31
+
32
+ function resolveMountRoot(stateDir: string): string {
33
+ return process.env.POND_WIKI_MOUNT_ROOT?.trim() || path.join(stateDir, "workspace");
34
+ }
35
+
36
+ function resolveWatchIntervalSec(): number {
37
+ const raw = process.env.POND_WIKI_REFRESH_INTERVAL_SEC?.trim();
38
+ if (!raw) {
39
+ return DEFAULT_WATCH_INTERVAL_SEC;
40
+ }
41
+ const parsed = Number.parseInt(raw, 10);
42
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_WATCH_INTERVAL_SEC;
43
+ }
44
+
45
+ function buildWikiHelperEnv(params: StartWikiHelperParams, mountRoot: string): NodeJS.ProcessEnv {
46
+ return {
47
+ ...process.env,
48
+ POND_API_URL: params.pondUrl,
49
+ POND_API_KEY: params.apiKey,
50
+ POND_ORG_ID: params.orgId,
51
+ POND_AGENT_ID: params.agentId,
52
+ POND_WIKI_MOUNT_ROOT: mountRoot,
53
+ };
54
+ }
55
+
56
+ async function runWikiSync(params: StartWikiHelperParams, env: NodeJS.ProcessEnv): Promise<"ok" | "missing" | "failed"> {
57
+ return new Promise((resolve) => {
58
+ let timedOut = false;
59
+ let settled = false;
60
+
61
+ const child = spawn("pond", ["wiki", "sync"], {
62
+ env,
63
+ stdio: "ignore",
64
+ });
65
+
66
+ const settle = (value: "ok" | "missing" | "failed") => {
67
+ if (settled) return;
68
+ settled = true;
69
+ resolve(value);
70
+ };
71
+
72
+ const timer = setTimeout(() => {
73
+ timedOut = true;
74
+ params.log?.warn?.(`pond[${params.accountId}]: pond sync timed out after ${DEFAULT_SYNC_TIMEOUT_MS}ms`);
75
+ child.kill("SIGTERM");
76
+ setTimeout(() => child.kill("SIGKILL"), 5_000).unref();
77
+ }, DEFAULT_SYNC_TIMEOUT_MS);
78
+ timer.unref();
79
+
80
+ child.on("error", (error) => {
81
+ clearTimeout(timer);
82
+ if (isCommandMissing(error)) {
83
+ params.log?.warn?.(`pond[${params.accountId}]: pond not installed, skipping wiki auto-sync/watch`);
84
+ settle("missing");
85
+ return;
86
+ }
87
+ params.log?.warn?.(`pond[${params.accountId}]: pond sync failed to start: ${String(error)}`);
88
+ settle("failed");
89
+ });
90
+
91
+ child.on("exit", (code, signal) => {
92
+ clearTimeout(timer);
93
+ if (timedOut) {
94
+ settle("failed");
95
+ return;
96
+ }
97
+ if (code === 0) {
98
+ params.log?.info?.(`pond[${params.accountId}]: pond sync completed`);
99
+ settle("ok");
100
+ return;
101
+ }
102
+ params.log?.warn?.(
103
+ `pond[${params.accountId}]: pond sync exited with code=${code ?? "null"} signal=${signal ?? "null"}`,
104
+ );
105
+ settle("failed");
106
+ });
107
+ });
108
+ }
109
+
110
+ export async function startWikiHelper(params: StartWikiHelperParams): Promise<WikiHelperRuntime> {
111
+ const mountRoot = resolveMountRoot(params.stateDir);
112
+ const env = buildWikiHelperEnv(params, mountRoot);
113
+
114
+ const syncResult = await runWikiSync(params, env);
115
+ if (syncResult === "missing") {
116
+ return {
117
+ mountRoot,
118
+ stop() {},
119
+ };
120
+ }
121
+
122
+ const intervalSec = resolveWatchIntervalSec();
123
+ let stopped = false;
124
+ const watch = spawn("pond", ["wiki", "watch", "--interval-sec", String(intervalSec)], {
125
+ env,
126
+ stdio: "ignore",
127
+ });
128
+
129
+ watch.on("error", (error) => {
130
+ if (stopped) return;
131
+ if (isCommandMissing(error)) {
132
+ params.log?.warn?.(`pond[${params.accountId}]: pond disappeared before watch could start`);
133
+ return;
134
+ }
135
+ params.log?.warn?.(`pond[${params.accountId}]: pond watch failed: ${String(error)}`);
136
+ });
137
+
138
+ watch.on("exit", (code, signal) => {
139
+ if (stopped) return;
140
+ params.log?.warn?.(
141
+ `pond[${params.accountId}]: pond watch exited with code=${code ?? "null"} signal=${signal ?? "null"}`,
142
+ );
143
+ });
144
+
145
+ params.log?.info?.(`pond[${params.accountId}]: pond watch started (interval=${intervalSec}s)`);
146
+
147
+ return {
148
+ mountRoot,
149
+ stop() {
150
+ stopped = true;
151
+ if (watch.exitCode === null && watch.signalCode === null) {
152
+ watch.kill("SIGTERM");
153
+ setTimeout(() => watch.kill("SIGKILL"), 5_000).unref();
154
+ }
155
+ },
156
+ };
157
+ }