@pnds/pond 1.1.0 → 1.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/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,42 @@ async function resolveClientForHook(sessionKey: string | undefined) {
24
25
  return { ...state, chatId, activeRunId };
25
26
  }
26
27
 
27
- const POND_CLI_CONTEXT = `## Pond CLI
28
-
29
- You have access to the Pond platform via the \`@pnds/cli\` CLI. Auth is pre-configuredjust run commands directly.
30
-
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
- \`\`\`
47
-
48
- Run \`npx @pnds/cli@latest --help\` or \`npx @pnds/cli@latest <command> --help\` for full options. Output is JSON.
28
+ const ORCHESTRATOR_PREFIX = `## Orchestrator Model
29
+
30
+ You are an orchestrator agent. Events arrive as structured \`[Event: ...]\` blocks. Your text output is internal reasoning only users cannot see it. All visible actions must go through tools.
31
+
32
+ ## Pond Action Tools
33
+
34
+ - \`pond_reply\` Send a text message to a chat. Params: \`chatId\`, \`text\`, optional \`noReply\`.
35
+ - \`pond_task_comment\` Post a comment on a task. Params: \`taskId\`, \`body\`.
36
+ - \`pond_task_update\` Update task fields (status, title, description, priority). Params: \`taskId\`, plus optional fields.
37
+ - \`pond_chat_history\` — Read recent messages from a chat (default 20, max 50). Use this to get context on unfamiliar chats. Params: \`chatId\`, optional \`limit\`, \`before\`, \`threadRootId\`.
38
+ - \`pond_typing\` Start or stop the typing indicator. Params: \`chatId\`, \`action\` ("start" | "stop").
39
+
40
+ ## Task Handling
41
+
42
+ Use \`pond_task_comment\` to communicate progress on tasks. Use \`pond_task_update\` to change task status (e.g., in_progress, done), title, description, or priority.
43
+
44
+ ## Sub-Agents
45
+
46
+ For long-running tasks, use \`sessions_spawn\` to delegate to a sub-agent. Return quickly so the orchestrator can process other events.
49
47
 
50
48
  ## Agent-to-Agent Interaction
51
49
 
52
50
  When you receive a message from another agent (not a human):
53
51
  - 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
52
  - 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).`;
53
+ - Replies go through \`pond_reply\` — use \`noReply: true\` when sending messages that don't require a response (e.g., delivering results, FYI notifications).`;
54
+
55
+ // Load wiki tools, CLI docs, and other tool docs from TOOLS.md (maintained separately)
56
+ const POND_ORCHESTRATOR_CONTEXT = ORCHESTRATOR_PREFIX + "\n\n" + loadToolsMarkdown();
56
57
 
57
58
  export function registerPondHooks(api: OpenClawPluginApi) {
58
59
  const log = api.logger;
59
60
 
60
61
  // Inject CLI awareness into agent system prompt
61
62
  api.on("before_prompt_build", () => {
62
- return { prependContext: POND_CLI_CONTEXT };
63
+ return { prependContext: POND_ORCHESTRATOR_CONTEXT };
63
64
  });
64
65
 
65
66
  // before_tool_call -> send tool_call step to AgentRun
@@ -125,6 +126,10 @@ export function registerPondHooks(api: OpenClawPluginApi) {
125
126
  log?.warn(`pond hook agent_end failed: ${String(err)}`);
126
127
  } finally {
127
128
  // Always clear local state — even if the server call failed, the run is over locally
129
+ if (ctx.sessionKey) {
130
+ clearSessionMessageId(ctx.sessionKey);
131
+ clearDispatchMessageId(ctx.sessionKey);
132
+ }
128
133
  if (state && ctx.sessionKey) {
129
134
  state.activeRuns.delete(ctx.sessionKey.toLowerCase());
130
135
  }
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
+ }