@vincentwei1021/synapse-openclaw-plugin 0.5.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/README.md ADDED
@@ -0,0 +1,95 @@
1
+ # @vincentwei1021/synapse-openclaw-plugin
2
+
3
+ OpenClaw plugin for [Synapse](https://github.com/Vincentwei1021/Synapse) -- the AI research orchestration platform.
4
+
5
+ Connects OpenClaw agents to Synapse via a persistent SSE connection and MCP tool bridge, enabling autonomous experiment execution, deep research, progress reporting, and report generation.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ openclaw plugins install @vincentwei1021/synapse-openclaw-plugin
11
+ ```
12
+
13
+ ## Configuration
14
+
15
+ Add to `~/.openclaw/openclaw.json`:
16
+
17
+ ```json
18
+ {
19
+ "hooks": {
20
+ "enabled": true,
21
+ "token": "your-hooks-token"
22
+ },
23
+ "plugins": {
24
+ "enabled": true,
25
+ "entries": {
26
+ "synapse-openclaw-plugin": {
27
+ "enabled": true,
28
+ "config": {
29
+ "synapseUrl": "https://synapse.example.com",
30
+ "apiKey": "syn_your_api_key_here",
31
+ "autoStart": true
32
+ }
33
+ }
34
+ }
35
+ }
36
+ }
37
+ ```
38
+
39
+ | Field | Type | Required | Default | Description |
40
+ |-------|------|----------|---------|-------------|
41
+ | `synapseUrl` | `string` | Yes | -- | Synapse server URL |
42
+ | `apiKey` | `string` | Yes | -- | Synapse API key (`syn_` prefix) |
43
+ | `projectUuids` | `string[]` | No | `[]` | Project UUIDs to monitor (empty = all) |
44
+ | `autoStart` | `boolean` | No | `true` | Auto-claim experiment runs on assignment |
45
+
46
+ ## How It Works
47
+
48
+ 1. **SSE listener** maintains a persistent connection to `/api/events/notifications` for real-time events
49
+ 2. **Event router** maps notifications to agent actions (experiment assignments, autonomous loop, deep research, mentions, etc.)
50
+ 3. **Agent trigger** dispatches isolated agent turns via OpenClaw's `/hooks/agent` endpoint
51
+ 4. **MCP tools** are registered as native OpenClaw agent tools, bridging to Synapse's MCP server at `/api/mcp`
52
+
53
+ ### Event Handling
54
+
55
+ | Event | Behavior |
56
+ |-------|----------|
57
+ | `task_assigned` (experiment) | Fetch experiment + project context, wake agent with full assignment prompt |
58
+ | `autonomous_loop_triggered` | Wake agent to analyze project and propose new experiments |
59
+ | `deep_research_requested` | Wake agent to perform literature review |
60
+ | `experiment_report_requested` | Wake agent to write a detailed experiment report |
61
+ | `mentioned` | Wake agent with @mention context |
62
+ | `hypothesis_formulation_requested` | Wake agent to review hypothesis formulation questions |
63
+ | `hypothesis_formulation_answered` | Wake agent to validate answers |
64
+ | `research_question_claimed` | Wake agent when assigned a research question |
65
+
66
+ ### Registered MCP Tools
67
+
68
+ All Synapse MCP tools are available to all agents. The plugin registers them as native OpenClaw tools via passthrough to the Synapse MCP server.
69
+
70
+ ## Local Development
71
+
72
+ ```json
73
+ {
74
+ "plugins": {
75
+ "enabled": true,
76
+ "load": {
77
+ "paths": ["/path/to/Synapse/packages/openclaw-plugin"]
78
+ },
79
+ "entries": {
80
+ "synapse-openclaw-plugin": {
81
+ "enabled": true,
82
+ "config": {
83
+ "synapseUrl": "http://localhost:3000",
84
+ "apiKey": "syn_your_dev_key",
85
+ "autoStart": true
86
+ }
87
+ }
88
+ }
89
+ }
90
+ }
91
+ ```
92
+
93
+ ## License
94
+
95
+ MIT
@@ -0,0 +1,28 @@
1
+ {
2
+ "id": "synapse-openclaw-plugin",
3
+ "configSchema": {
4
+ "type": "object",
5
+ "additionalProperties": false,
6
+ "properties": {
7
+ "synapseUrl": {
8
+ "type": "string",
9
+ "description": "Synapse server URL"
10
+ },
11
+ "apiKey": {
12
+ "type": "string",
13
+ "description": "Synapse API Key (syn_ prefix)"
14
+ },
15
+ "projectUuids": {
16
+ "type": "array",
17
+ "items": { "type": "string" },
18
+ "default": [],
19
+ "description": "Project UUIDs to monitor (empty = all)"
20
+ },
21
+ "autoStart": {
22
+ "type": "boolean",
23
+ "default": true,
24
+ "description": "Auto-claim experiment runs on assignment"
25
+ }
26
+ }
27
+ }
28
+ }
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@vincentwei1021/synapse-openclaw-plugin",
3
+ "version": "0.5.0",
4
+ "description": "OpenClaw plugin for Synapse research orchestration — SSE notifications, MCP tools, experiment lifecycle, autonomous loop",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "src/index.ts",
8
+ "scripts": {
9
+ "typecheck": "tsc --noEmit",
10
+ "test": "cd ../.. && pnpm exec vitest run packages/openclaw-plugin/src/event-router.test.ts packages/openclaw-plugin/src/mcp-client.test.ts packages/openclaw-plugin/src/sse-listener.test.ts"
11
+ },
12
+ "peerDependencies": {
13
+ "openclaw": ">=2026.0.0"
14
+ },
15
+ "dependencies": {
16
+ "@modelcontextprotocol/sdk": "^1.26.0",
17
+ "zod": "^3.23.0"
18
+ },
19
+ "devDependencies": {
20
+ "typescript": "^5.9.0"
21
+ },
22
+ "openclaw": {
23
+ "extensions": ["src/index.ts"]
24
+ },
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "https://github.com/Vincentwei1021/Synapse",
28
+ "directory": "packages/openclaw-plugin"
29
+ },
30
+ "homepage": "https://github.com/Vincentwei1021/Synapse",
31
+ "keywords": ["openclaw", "synapse", "research", "mcp", "plugin", "agent", "experiments"],
32
+ "files": [
33
+ "src",
34
+ "openclaw.plugin.json",
35
+ "README.md"
36
+ ]
37
+ }
@@ -0,0 +1,151 @@
1
+ import type { SynapseMcpClient } from "./mcp-client.js";
2
+
3
+ // ===== Response types from Synapse MCP tools =====
4
+
5
+ interface CheckinResponse {
6
+ checkinTime: string;
7
+ agent: {
8
+ uuid: string;
9
+ name: string;
10
+ roles: string[];
11
+ persona: string | null;
12
+ systemPrompt: string | null;
13
+ };
14
+ assignments: {
15
+ researchQuestions?: AssignedQuestion[];
16
+ experimentRuns?: AssignedRun[];
17
+ };
18
+ pending: {
19
+ researchQuestionsCount?: number;
20
+ experimentRunsCount?: number;
21
+ };
22
+ notifications: {
23
+ unreadCount: number;
24
+ };
25
+ }
26
+
27
+ interface AssignedQuestion {
28
+ uuid: string;
29
+ title: string;
30
+ status: string;
31
+ project: { uuid: string; name: string };
32
+ }
33
+
34
+ interface AssignedRun {
35
+ uuid: string;
36
+ title: string;
37
+ status: string;
38
+ priority: string;
39
+ project: { uuid: string; name: string };
40
+ }
41
+
42
+ interface AssignmentsResponse {
43
+ researchQuestions?: AssignedQuestion[];
44
+ experimentRuns?: AssignedRun[];
45
+ }
46
+
47
+ // ===== Formatting helpers =====
48
+
49
+ function formatStatus(checkin: CheckinResponse, connectionStatus: string): string {
50
+ const questionCount = checkin?.pending?.researchQuestionsCount ?? 0;
51
+ const runCount = checkin?.pending?.experimentRunsCount ?? 0;
52
+ const lines: string[] = [
53
+ `Connection: ${connectionStatus}`,
54
+ `Assignments: ${questionCount} questions, ${runCount} experiments`,
55
+ `Notifications: ${checkin?.notifications?.unreadCount ?? 0} unread`,
56
+ ];
57
+ return lines.join("\n");
58
+ }
59
+
60
+ function formatExperimentList(runs: AssignedRun[] | undefined): string {
61
+ if (!runs?.length) {
62
+ return "No assigned experiments.";
63
+ }
64
+
65
+ const lines = runs.map(
66
+ (r) => `[${r.status}] [${r.priority}] ${r.title} (${r.project.name})`
67
+ );
68
+ return `Assigned experiments (${runs.length}):\n${lines.join("\n")}`;
69
+ }
70
+
71
+ function formatQuestionList(questions: AssignedQuestion[] | undefined): string {
72
+ if (!questions?.length) {
73
+ return "No assigned research questions.";
74
+ }
75
+
76
+ const lines = questions.map(
77
+ (q) => `[${q.status}] ${q.title} (${q.project.name})`
78
+ );
79
+ return `Assigned research questions (${questions.length}):\n${lines.join("\n")}`;
80
+ }
81
+
82
+ const HELP_TEXT = [
83
+ "Synapse commands:",
84
+ " /synapse Show connection status and summary",
85
+ " /synapse status Same as above",
86
+ " /synapse experiments List assigned experiments",
87
+ " /synapse questions List assigned research questions",
88
+ ].join("\n");
89
+
90
+ interface CommandRegistry {
91
+ registerCommand(command: {
92
+ name: string;
93
+ description: string;
94
+ handler: (ctx: { args: string }) => Promise<{ text: string }>;
95
+ }): void;
96
+ }
97
+
98
+ // ===== Registration =====
99
+
100
+ export function registerSynapseCommands(
101
+ api: CommandRegistry,
102
+ mcpClient: SynapseMcpClient,
103
+ getStatus: () => string
104
+ ): void {
105
+ api.registerCommand({
106
+ name: "synapse",
107
+ description: "Synapse plugin commands: status, experiments, questions",
108
+ async handler(ctx: { args: string }) {
109
+ const sub = (ctx.args ?? "").trim().toLowerCase();
110
+
111
+ // /synapse or /synapse status
112
+ if (!sub || sub === "status") {
113
+ try {
114
+ const checkin = (await mcpClient.callTool("synapse_checkin", {})) as CheckinResponse;
115
+ return { text: formatStatus(checkin, getStatus()) };
116
+ } catch (err) {
117
+ return { text: `Failed to check in: ${err instanceof Error ? err.message : String(err)}` };
118
+ }
119
+ }
120
+
121
+ // /synapse experiments
122
+ if (sub === "experiments" || sub === "tasks") {
123
+ try {
124
+ const data = (await mcpClient.callTool(
125
+ "synapse_get_my_assignments",
126
+ {}
127
+ )) as AssignmentsResponse;
128
+ return { text: formatExperimentList(data?.experimentRuns) };
129
+ } catch (err) {
130
+ return { text: `Failed to fetch experiments: ${err instanceof Error ? err.message : String(err)}` };
131
+ }
132
+ }
133
+
134
+ // /synapse questions
135
+ if (sub === "questions" || sub === "ideas") {
136
+ try {
137
+ const data = (await mcpClient.callTool(
138
+ "synapse_get_my_assignments",
139
+ {}
140
+ )) as AssignmentsResponse;
141
+ return { text: formatQuestionList(data?.researchQuestions) };
142
+ } catch (err) {
143
+ return { text: `Failed to fetch research questions: ${err instanceof Error ? err.message : String(err)}` };
144
+ }
145
+ }
146
+
147
+ // Unknown subcommand
148
+ return { text: HELP_TEXT };
149
+ },
150
+ });
151
+ }
package/src/config.ts ADDED
@@ -0,0 +1,56 @@
1
+ import { z } from "zod";
2
+
3
+ export const CONFIG_FILE_PATH = "~/.openclaw/openclaw.json";
4
+ export const CONFIG_KEY_PATH = "plugins.entries.synapse-openclaw-plugin.config";
5
+
6
+ export const synapseConfigSchema = z.object({
7
+ synapseUrl: z
8
+ .string()
9
+ .url()
10
+ .optional()
11
+ .describe("Synapse server URL (e.g. https://synapse.example.com)"),
12
+ apiKey: z
13
+ .string()
14
+ .startsWith("syn_")
15
+ .optional()
16
+ .describe("Synapse API Key (syn_ prefix)"),
17
+ projectUuids: z
18
+ .array(z.string().uuid())
19
+ .optional()
20
+ .default([])
21
+ .describe("Project UUIDs to monitor. Empty = all projects"),
22
+ autoStart: z
23
+ .boolean()
24
+ .optional()
25
+ .default(true)
26
+ .describe("Auto-claim and start work on task_assigned events"),
27
+ });
28
+
29
+ export type SynapsePluginConfig = z.infer<typeof synapseConfigSchema>;
30
+
31
+ /**
32
+ * Check required config fields and warn about missing ones.
33
+ * Returns true if all required fields are present, false otherwise.
34
+ */
35
+ export function validateConfigWithWarnings(
36
+ config: SynapsePluginConfig,
37
+ logger: { warn: (msg: string) => void },
38
+ ): boolean {
39
+ const missing: string[] = [];
40
+
41
+ if (!config.synapseUrl) {
42
+ missing.push(` - "synapseUrl": set at ${CONFIG_KEY_PATH}.synapseUrl in ${CONFIG_FILE_PATH}`);
43
+ }
44
+ if (!config.apiKey) {
45
+ missing.push(` - "apiKey": set at ${CONFIG_KEY_PATH}.apiKey in ${CONFIG_FILE_PATH}`);
46
+ }
47
+
48
+ if (missing.length > 0) {
49
+ logger.warn(
50
+ `[Synapse] Plugin is missing required configuration. Features will be disabled until configured:\n` +
51
+ missing.join("\n")
52
+ );
53
+ return false;
54
+ }
55
+ return true;
56
+ }
@@ -0,0 +1,265 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { SynapseEventRouter } from "./event-router.js";
3
+
4
+ function createLogger() {
5
+ return {
6
+ info: vi.fn(),
7
+ warn: vi.fn(),
8
+ error: vi.fn(),
9
+ };
10
+ }
11
+
12
+ describe("SynapseEventRouter", () => {
13
+ const triggerAgent = vi.fn();
14
+ const callTool = vi.fn();
15
+ const logger = createLogger();
16
+
17
+ beforeEach(() => {
18
+ vi.clearAllMocks();
19
+ });
20
+
21
+ it("routes experiment assignments with full context and unlimited budget text", async () => {
22
+ callTool
23
+ .mockResolvedValueOnce({
24
+ notifications: [
25
+ {
26
+ uuid: "notification-1",
27
+ researchProjectUuid: "project-1",
28
+ entityType: "experiment",
29
+ entityUuid: "experiment-1",
30
+ entityTitle: "Train the baseline",
31
+ action: "task_assigned",
32
+ message: "Assigned to you",
33
+ actorType: "user",
34
+ actorUuid: "user-1",
35
+ actorName: "Alice",
36
+ },
37
+ ],
38
+ })
39
+ .mockResolvedValueOnce({
40
+ experiment: {
41
+ uuid: "experiment-1",
42
+ researchProjectUuid: "project-1",
43
+ title: "Train the baseline",
44
+ description: "Run the first baseline.",
45
+ priority: "high",
46
+ computeBudgetHours: null,
47
+ attachments: [{ originalName: "spec.md" }],
48
+ researchQuestion: { uuid: "question-1", title: "Why is recall dropping?" },
49
+ parentQuestionExperiments: [],
50
+ },
51
+ })
52
+ .mockResolvedValueOnce({
53
+ uuid: "project-1",
54
+ name: "Recall recovery",
55
+ description: "Improve retrieval quality",
56
+ goal: "Raise recall by 5 points",
57
+ datasets: ["train.jsonl"],
58
+ evaluationMethods: ["recall@10"],
59
+ });
60
+
61
+ const router = new SynapseEventRouter({
62
+ mcpClient: { callTool } as never,
63
+ config: {
64
+ synapseUrl: "http://synapse.local",
65
+ apiKey: "syn_key",
66
+ autoStart: true,
67
+ projectUuids: [],
68
+ },
69
+ triggerAgent,
70
+ logger,
71
+ });
72
+
73
+ await (router as unknown as { fetchAndRoute: (notificationUuid: string) => Promise<void> }).fetchAndRoute("notification-1");
74
+
75
+ expect(triggerAgent).toHaveBeenCalledTimes(1);
76
+ const [prompt, metadata] = triggerAgent.mock.calls[0];
77
+ expect(prompt).toContain("Experiment assigned: Train the baseline");
78
+ expect(prompt).toContain("Compute budget (hours): Unlimited");
79
+ expect(prompt).toContain("post a comment on this experiment");
80
+ expect(prompt).toContain("@[Alice](user:user-1)");
81
+ expect(prompt).toContain("synapse_report_experiment_progress");
82
+ expect(metadata).toMatchObject({
83
+ action: "task_assigned",
84
+ entityType: "experiment",
85
+ entityUuid: "experiment-1",
86
+ projectUuid: "project-1",
87
+ });
88
+ });
89
+
90
+ it("skips notifications outside the configured project filter", async () => {
91
+ callTool.mockResolvedValueOnce({
92
+ notifications: [
93
+ {
94
+ uuid: "notification-2",
95
+ researchProjectUuid: "project-2",
96
+ entityType: "experiment",
97
+ entityUuid: "experiment-2",
98
+ entityTitle: "Ignored experiment",
99
+ action: "task_assigned",
100
+ message: "Assigned to you",
101
+ actorType: "user",
102
+ actorUuid: "user-2",
103
+ actorName: "Bob",
104
+ },
105
+ ],
106
+ });
107
+
108
+ const router = new SynapseEventRouter({
109
+ mcpClient: { callTool } as never,
110
+ config: {
111
+ synapseUrl: "http://synapse.local",
112
+ apiKey: "syn_key",
113
+ autoStart: true,
114
+ projectUuids: ["project-allowed"],
115
+ },
116
+ triggerAgent,
117
+ logger,
118
+ });
119
+
120
+ await (router as unknown as { fetchAndRoute: (notificationUuid: string) => Promise<void> }).fetchAndRoute("notification-2");
121
+
122
+ expect(triggerAgent).not.toHaveBeenCalled();
123
+ expect(callTool).toHaveBeenCalledTimes(1);
124
+ expect(logger.info).toHaveBeenCalledWith("Notification for project project-2 filtered out");
125
+ });
126
+
127
+ it("routes autonomous loop triggered events", async () => {
128
+ callTool.mockResolvedValueOnce({
129
+ notifications: [
130
+ {
131
+ uuid: "notification-3",
132
+ researchProjectUuid: "project-1",
133
+ entityType: "research_project",
134
+ entityUuid: "project-1",
135
+ entityTitle: "My Project",
136
+ action: "autonomous_loop_triggered",
137
+ message: "Queue empty",
138
+ actorType: "system",
139
+ actorUuid: "system",
140
+ actorName: "Synapse",
141
+ },
142
+ ],
143
+ });
144
+
145
+ const router = new SynapseEventRouter({
146
+ mcpClient: { callTool } as never,
147
+ config: {
148
+ synapseUrl: "http://synapse.local",
149
+ apiKey: "syn_key",
150
+ autoStart: true,
151
+ projectUuids: [],
152
+ },
153
+ triggerAgent,
154
+ logger,
155
+ });
156
+
157
+ await (router as unknown as { fetchAndRoute: (notificationUuid: string) => Promise<void> }).fetchAndRoute("notification-3");
158
+
159
+ expect(triggerAgent).toHaveBeenCalledTimes(1);
160
+ const [prompt, metadata] = triggerAgent.mock.calls[0];
161
+ expect(prompt).toContain("Autonomous research loop triggered");
162
+ expect(prompt).toContain("synapse_propose_experiment");
163
+ expect(metadata).toMatchObject({
164
+ action: "autonomous_loop_triggered",
165
+ projectUuid: "project-1",
166
+ });
167
+ });
168
+
169
+ it("routes experiment report requested events", async () => {
170
+ callTool.mockResolvedValueOnce({
171
+ notifications: [
172
+ {
173
+ uuid: "notification-4",
174
+ researchProjectUuid: "project-1",
175
+ entityType: "experiment",
176
+ entityUuid: "experiment-1",
177
+ entityTitle: "Baseline experiment",
178
+ action: "experiment_report_requested",
179
+ message: "Write report",
180
+ actorType: "system",
181
+ actorUuid: "system",
182
+ actorName: "Synapse",
183
+ },
184
+ ],
185
+ });
186
+
187
+ const router = new SynapseEventRouter({
188
+ mcpClient: { callTool } as never,
189
+ config: {
190
+ synapseUrl: "http://synapse.local",
191
+ apiKey: "syn_key",
192
+ autoStart: true,
193
+ projectUuids: [],
194
+ },
195
+ triggerAgent,
196
+ logger,
197
+ });
198
+
199
+ await (router as unknown as { fetchAndRoute: (notificationUuid: string) => Promise<void> }).fetchAndRoute("notification-4");
200
+
201
+ expect(triggerAgent).toHaveBeenCalledTimes(1);
202
+ const [prompt] = triggerAgent.mock.calls[0];
203
+ expect(prompt).toContain("Baseline experiment");
204
+ expect(prompt).toContain("synapse_add_comment");
205
+ });
206
+
207
+ it("routes @mention events with entity context", async () => {
208
+ callTool.mockResolvedValueOnce({
209
+ notifications: [
210
+ {
211
+ uuid: "notification-5",
212
+ researchProjectUuid: "project-1",
213
+ entityType: "experiment",
214
+ entityUuid: "experiment-1",
215
+ entityTitle: "Recall test",
216
+ action: "mentioned",
217
+ message: "@Agent please review",
218
+ actorType: "user",
219
+ actorUuid: "user-1",
220
+ actorName: "Alice",
221
+ },
222
+ ],
223
+ });
224
+
225
+ const router = new SynapseEventRouter({
226
+ mcpClient: { callTool } as never,
227
+ config: {
228
+ synapseUrl: "http://synapse.local",
229
+ apiKey: "syn_key",
230
+ autoStart: true,
231
+ projectUuids: [],
232
+ },
233
+ triggerAgent,
234
+ logger,
235
+ });
236
+
237
+ await (router as unknown as { fetchAndRoute: (notificationUuid: string) => Promise<void> }).fetchAndRoute("notification-5");
238
+
239
+ expect(triggerAgent).toHaveBeenCalledTimes(1);
240
+ const [prompt] = triggerAgent.mock.calls[0];
241
+ expect(prompt).toContain("@mentioned");
242
+ expect(prompt).toContain("synapse_get_comments");
243
+ expect(prompt).toContain("@[Alice](user:user-1)");
244
+ });
245
+
246
+ it("ignores non-new_notification event types", () => {
247
+ const router = new SynapseEventRouter({
248
+ mcpClient: { callTool } as never,
249
+ config: {
250
+ synapseUrl: "http://synapse.local",
251
+ apiKey: "syn_key",
252
+ autoStart: true,
253
+ projectUuids: [],
254
+ },
255
+ triggerAgent,
256
+ logger,
257
+ });
258
+
259
+ router.dispatch({ type: "count_update", unreadCount: 5 } as unknown as import("./sse-listener.js").SseNotificationEvent);
260
+
261
+ expect(triggerAgent).not.toHaveBeenCalled();
262
+ expect(callTool).not.toHaveBeenCalled();
263
+ expect(logger.info).toHaveBeenCalledWith('SSE event type "count_update" ignored');
264
+ });
265
+ });