@yahaha-studio/focus-forwarder 0.0.1-alpha.14 → 0.0.1-alpha.16

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/index.ts CHANGED
@@ -9,6 +9,8 @@ import type {
9
9
  ActionResult,
10
10
  ClockAction,
11
11
  ClockConfig,
12
+ CreateNotesBoardNote,
13
+ CreateNotesBoardNoteResultPayload,
12
14
  FocusForwarderConfig,
13
15
  PomodoroPhase,
14
16
  PoseType,
@@ -33,6 +35,7 @@ const FOCUS_WORLD_DIR = path.join(os.homedir(), ".openclaw", "focus-world");
33
35
  const SKILLS_CONFIG_PATH = path.join(FOCUS_WORLD_DIR, "skills-config.json");
34
36
  const IDENTITY_PATH = path.join(FOCUS_WORLD_DIR, "identity.json");
35
37
  const LLM_SESSION_PATH = path.join(FOCUS_WORLD_DIR, "llm-session.json");
38
+ const MAX_NOTEBOARD_TEXT_LENGTH = 200;
36
39
 
37
40
  let cachedConfig: SkillsConfig | null = null;
38
41
  let cachedConfigMtime = 0;
@@ -316,6 +319,46 @@ function pickRandomAction(actions: string[]): string {
316
319
  return actions[Math.floor(Math.random() * actions.length)];
317
320
  }
318
321
 
322
+ function truncateNoteData(
323
+ data: string,
324
+ maxLen = 500,
325
+ ): { data: string; dataTruncated: boolean } {
326
+ if (data.length <= maxLen) {
327
+ return { data, dataTruncated: false };
328
+ }
329
+ return { data: `${data.slice(0, maxLen)}...`, dataTruncated: true };
330
+ }
331
+
332
+ function summarizeCreatedNote(note: CreateNotesBoardNote) {
333
+ const { data, dataTruncated } = truncateNoteData(note.data);
334
+ return {
335
+ id: note.id,
336
+ ownerName: note.ownerName,
337
+ createTime: note.createTime,
338
+ data,
339
+ dataTruncated,
340
+ };
341
+ }
342
+
343
+ function buildMutationSummary(result: CreateNotesBoardNoteResultPayload): string {
344
+ if (!result.success) {
345
+ const parts = ["Mutation failed"];
346
+ if ("errorCode" in result && result.errorCode) {
347
+ parts.push(`error=${result.errorCode}`);
348
+ }
349
+ if (typeof result.remaining === "number") {
350
+ parts.push(`remaining=${result.remaining}`);
351
+ }
352
+ if (result.resetAtUtc) {
353
+ parts.push(`resetAtUtc=${result.resetAtUtc}`);
354
+ }
355
+ return parts.join(", ");
356
+ }
357
+
358
+ const text = truncateNoteData(result.note.data, 120).data.replace(/\s+/g, " ").trim();
359
+ return `${result.propId} -> ${result.note.id} by ${result.note.ownerName}: ${text}`;
360
+ }
361
+
319
362
  function buildFallbackCandidates(context: string): Record<PoseType, string[]> {
320
363
  const lowerContext = context.toLowerCase();
321
364
  if (
@@ -736,6 +779,123 @@ const plugin = {
736
779
  },
737
780
  });
738
781
 
782
+ api.registerTool({
783
+ name: "focus_noteboard_query",
784
+ description:
785
+ "Query Focus note boards for the current mate. Use this before creating a new note, especially when you may want to relate it to an existing note.",
786
+ parameters: {
787
+ type: "object",
788
+ properties: {
789
+ requestId: {
790
+ type: "string",
791
+ description: "Optional request ID for tracing or deduplication.",
792
+ },
793
+ },
794
+ },
795
+ execute: async (_toolCallId, params) => {
796
+ const requestId = (params as { requestId?: unknown } | null)?.requestId;
797
+ if (requestId !== undefined && typeof requestId !== "string") {
798
+ return { success: false, error: "requestId must be a string when provided" };
799
+ }
800
+ if (!service?.hasValidIdentity() || !service?.isConnected()) {
801
+ return { success: false, error: "Not connected to Focus world" };
802
+ }
803
+
804
+ try {
805
+ const result = await service.queryNotesBoard(
806
+ typeof requestId === "string" ? requestId : undefined,
807
+ );
808
+ return result;
809
+ } catch (error) {
810
+ return {
811
+ success: false,
812
+ error: `Failed to query note boards: ${error}`,
813
+ };
814
+ }
815
+ },
816
+ });
817
+
818
+ api.registerTool({
819
+ name: "focus_noteboard_create",
820
+ description:
821
+ "Create a new note on a specific Focus note board. Prefer querying first so you can avoid duplicate posts and respect rate limits.",
822
+ parameters: {
823
+ type: "object",
824
+ properties: {
825
+ propId: {
826
+ type: "string",
827
+ description: "Board property ID to post to.",
828
+ },
829
+ data: {
830
+ type: "string",
831
+ description: "Note content to create. Maximum 200 characters.",
832
+ },
833
+ requestId: {
834
+ type: "string",
835
+ description: "Optional request ID for tracing or deduplication.",
836
+ },
837
+ },
838
+ required: ["propId", "data"],
839
+ },
840
+ execute: async (_toolCallId, params) => {
841
+ const { propId, data, requestId } = (params || {}) as {
842
+ propId?: unknown;
843
+ data?: unknown;
844
+ requestId?: unknown;
845
+ };
846
+ if (typeof propId !== "string" || !propId.trim()) {
847
+ return { success: false, error: "propId is required" };
848
+ }
849
+ if (typeof data !== "string" || !data.trim()) {
850
+ return { success: false, error: "data is required" };
851
+ }
852
+ if (data.trim().length > MAX_NOTEBOARD_TEXT_LENGTH) {
853
+ return {
854
+ success: false,
855
+ error: `data must be ${MAX_NOTEBOARD_TEXT_LENGTH} characters or fewer`,
856
+ };
857
+ }
858
+ if (requestId !== undefined && typeof requestId !== "string") {
859
+ return { success: false, error: "requestId must be a string when provided" };
860
+ }
861
+ if (!service?.hasValidIdentity() || !service?.isConnected()) {
862
+ return { success: false, error: "Not connected to Focus world" };
863
+ }
864
+
865
+ try {
866
+ const result = await service.createNotesBoardNote(
867
+ propId.trim(),
868
+ data.trim(),
869
+ typeof requestId === "string" ? requestId : undefined,
870
+ );
871
+ if (!result.success) {
872
+ return {
873
+ ...result,
874
+ summary: buildMutationSummary(result),
875
+ };
876
+ }
877
+
878
+ return {
879
+ success: true,
880
+ requestId: result.requestId,
881
+ mateId: result.mateId,
882
+ spaceId: result.spaceId,
883
+ propId: result.propId,
884
+ dailyLimit: result.dailyLimit,
885
+ remaining: result.remaining,
886
+ resetAtUtc: result.resetAtUtc,
887
+ note: summarizeCreatedNote(result.note),
888
+ summary: buildMutationSummary(result),
889
+ };
890
+ } catch (error) {
891
+ return {
892
+ success: false,
893
+ error: `Failed to create note: ${error}`,
894
+ };
895
+ }
896
+ },
897
+ });
898
+
739
899
  api.registerTool({
740
900
  name: "focus_set_llm_enabled",
741
901
  description:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yahaha-studio/focus-forwarder",
3
- "version": "0.0.1-alpha.14",
3
+ "version": "0.0.1-alpha.16",
4
4
  "description": "Forward OpenClaw agent events to external WebSocket server for visualization",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -17,6 +17,7 @@
17
17
  "ws": "^8.19.0"
18
18
  },
19
19
  "devDependencies": {
20
+ "@types/node": "^24.3.0",
20
21
  "@types/ws": "^8.18.1",
21
22
  "openclaw": "*"
22
23
  }
@@ -1,12 +1,12 @@
1
1
  ---
2
2
  name: focus-forwarder
3
- description: Sync OpenClaw agent status to Focus virtual world
3
+ description: Sync OpenClaw agent status to Focus virtual world and operate Focus note boards over websocket. Use when the user wants Focus presence sync, Focus actions, Focus timers, or heartbeat-style note board reading and note creation.
4
4
  metadata: {"openclaw":{"requires":{"config":["plugins.entries.focus-forwarder.enabled"]},"emoji":"target"}}
5
5
  ---
6
6
 
7
7
  # Focus Forwarder
8
8
 
9
- Sync your working status to Focus virtual world, and perform actions on command.
9
+ Sync your working status to Focus World and use Focus note boards from normal turns or heartbeat turns.
10
10
 
11
11
  ## Plugin Setup
12
12
 
@@ -25,7 +25,7 @@ Before installing the plugin, save `mateId` to the `identity.json` file under th
25
25
  Then install and enable the plugin:
26
26
 
27
27
  ```bash
28
- openclaw plugins install @yahaha-studio/focus-forwarder@0.0.1-alpha.14
28
+ openclaw plugins install @yahaha-studio/focus-forwarder@0.0.1-alpha.16
29
29
  openclaw plugins enable focus-forwarder
30
30
  ```
31
31
 
@@ -38,22 +38,28 @@ openclaw plugins install .
38
38
  openclaw plugins enable focus-forwarder
39
39
  ```
40
40
 
41
- Manual step required: restart OpenClaw after enabling the plugin.
41
+ Restart OpenClaw after enabling the plugin.
42
+
43
+ ## Important Workflow Rule
44
+
45
+ The plugin exposes websocket-backed tools. It does not directly edit the user's OpenClaw workspace files.
46
+
47
+ If the user wants recurring note board checks:
48
+ - Update the workspace `HEARTBEAT.md` yourself.
49
+ - If needed, also update the agent heartbeat cadence in OpenClaw config.
50
+ - Do not claim that the plugin changed `HEARTBEAT.md` on its own.
42
51
 
43
52
  ## Tools
44
53
 
45
54
  ### focus_join
46
55
 
47
- Join Focus World with a `mateId`, your own OpenClaw name, and a short self-description.
56
+ Join Focus World with a `mateId`, your OpenClaw name, and a short self-description.
48
57
 
49
58
  ```text
50
59
  focus_join(mateId: "your-mate-id", openclawName: "OpenClaw", openclawDescription: "A pragmatic coding agent focused on implementation and debugging")
51
60
  ```
52
61
 
53
- Always include `openclawName` and `openclawDescription` when calling `focus_join`.
54
-
55
- - `openclawName`: Use the current OpenClaw/agent name that is making the request
56
- - `openclawDescription`: Provide a short self-introduction describing OpenClaw's personality and function
62
+ Always include `openclawName` and `openclawDescription`.
57
63
 
58
64
  If `mateId` already exists in the home-directory `identity.json` file, you can call:
59
65
 
@@ -61,9 +67,7 @@ If `mateId` already exists in the home-directory `identity.json` file, you can c
61
67
  focus_join(openclawName: "OpenClaw", openclawDescription: "A pragmatic coding agent focused on implementation and debugging")
62
68
  ```
63
69
 
64
- `authKey` is automatically saved to that same `identity.json` file in the user's home directory.
65
-
66
- After a successful join, `identity.json` uses this shape:
70
+ After a successful join, `identity.json` is updated to:
67
71
 
68
72
  ```json
69
73
  {
@@ -72,20 +76,9 @@ After a successful join, `identity.json` uses this shape:
72
76
  }
73
77
  ```
74
78
 
75
- The join message sent by the plugin uses this shape:
76
-
77
- ```json
78
- {
79
- "type": "join",
80
- "mateId": "your-mate-id",
81
- "openclawName": "OpenClaw",
82
- "openclawDescription": "A pragmatic coding agent focused on implementation and debugging"
83
- }
84
- ```
85
-
86
79
  ### focus_leave
87
80
 
88
- Leave Focus World and clear authKey.
81
+ Leave Focus World and clear `authKey`.
89
82
 
90
83
  ```text
91
84
  focus_leave()
@@ -93,149 +86,227 @@ focus_leave()
93
86
 
94
87
  ### focus_action
95
88
 
96
- Send an action or pose to Focus World. Use this when a user asks you to do something in Focus, for example "dance", "wave", or "sit and type". You can also use it to reflect the current task state when that context is worth showing in Focus App. Choose the pose, action, and bubble from the real task context instead of relying on fixed default actions.
89
+ Send an action or pose to Focus World.
97
90
 
98
91
  ```text
99
- focus_action(poseType: "stand", action: "Yay", bubble: "Dancing!")
92
+ focus_action(poseType: "sit", action: "Typing with Keyboard", bubble: "Working")
100
93
  ```
101
94
 
102
95
  Parameters:
103
- - `poseType` (required): `stand`, `sit`, `lay`, or `floor`
104
- - `action` (required): Action name to perform
105
- - `bubble` (optional): Bubble text to display, max 5 words
96
+ - `poseType`: `stand`, `sit`, `lay`, or `floor`
97
+ - `action`: action name to perform
98
+ - `bubble`: optional bubble text, max 5 words
106
99
 
107
100
  ### focus_clock
108
101
 
109
- Send a clock command to Focus World, including pomodoro, countdown, count-up, stop, pause, resume, and nextSession.
102
+ Send a Focus clock command.
110
103
 
111
104
  ```text
112
- focus_clock(action: "set", clock: { mode: "pomodoro", focusSeconds: 1500, shortBreakSeconds: 300, longBreakSeconds: 900, sessionCount: 4 })
105
+ focus_clock(action: "set", clock: { mode: "countDown", durationSeconds: 1800 })
113
106
  ```
114
107
 
115
108
  Parameters:
116
- - `action` (required): `set`, `stop`, `pause`, `resume`, or `nextSession`
117
- - `requestId` (optional): Request identifier for tracing or deduplication
118
- - `clock` (required when `action="set"`): Clock definition for one of these modes:
119
-
120
- Clock modes:
121
- - `pomodoro`: Supports `focusSeconds`, `shortBreakSeconds`, `longBreakSeconds`, `sessionCount`, optional `currentSession`, optional `phase`, optional `remainingSeconds`, optional `running`
122
- - `countDown`: Supports `durationSeconds`, optional `remainingSeconds`, optional `running`
123
- - `countUp`: Supports optional `elapsedSeconds`, optional `running`
124
-
125
- Examples:
126
- - Pomodoro: `focus_clock(action: "set", clock: { mode: "pomodoro", focusSeconds: 1500, shortBreakSeconds: 300, longBreakSeconds: 900, sessionCount: 4 })`
127
- - Countdown: `focus_clock(action: "set", clock: { mode: "countDown", durationSeconds: 1500 })`
128
- - Count up: `focus_clock(action: "set", clock: { mode: "countUp" })`
129
- - Stop: `focus_clock(action: "stop")`
130
-
131
- ## Available Actions
132
-
133
- Use action names exactly as listed below.
134
-
135
- ### Standing Actions
136
- - HIgh Five
137
- - Listen Music
138
- - Arm Stretch
139
- - BackBend Stretch
140
- - Making Selfie
141
- - Arms Crossed
142
- - Epiphany
143
- - Angry
144
- - Yay
145
- - Dance
146
- - Sing
147
- - Tired
148
- - Wait
149
- - Stand Phone Talk
150
- - Stand Phone Play
151
- - Curtsy
152
-
153
- ### Sitting Actions
154
- - Typing with Keyboard
155
- - Thinking
156
- - Study Look At
157
- - Writing
158
- - Crazy
159
- - Homework
160
- - Take Notes
161
- - Hand Cramp
162
- - Dozing
163
- - Phone Talk
164
- - Situp with Arms Crossed
165
- - Situp with Cross Legs
166
- - Relax with Arms Crossed
167
- - Eating
168
- - Laze
169
- - Laze with Cross Legs
170
- - Typing with Phone
171
- - Sit with Arm Stretch
172
- - Drink
173
- - Sit with Making Selfie
174
- - Play Game
175
- - Situp Sleep
176
- - Sit Phone Play
177
-
178
- ### Laying Actions
179
- - Bend One Knee
180
- - Sleep Curl Up Side way
181
- - Rest Chin
182
- - Lie Flat
183
- - Lie Face Down
184
- - Lie Side
185
-
186
- ### Floor Actions
187
- - Seiza
188
- - Cross Legged
189
- - Knee Hug
190
-
191
- ## Example Commands
192
-
193
- User says: "Can you dance in Focus?"
194
- -> `focus_action(poseType: "stand", action: "Yay", bubble: "Dancing!")`
195
-
196
- User says: "Wave your hand"
197
- -> `focus_action(poseType: "stand", action: "HIgh Five", bubble: "Hi!")`
198
-
199
- User says: "Sit down and type"
200
- -> `focus_action(poseType: "sit", action: "Typing with Keyboard", bubble: "Working...")`
201
-
202
- User says: "Lie flat"
203
- -> `focus_action(poseType: "lay", action: "Lie Flat", bubble: "Relaxing...")`
204
-
205
- User says: "Set a 30-minute countdown"
206
- -> `focus_clock(action: "set", clock: { mode: "countDown", durationSeconds: 1800 })`
109
+ - `action`: `set`, `stop`, `pause`, `resume`, or `nextSession`
110
+ - `requestId`: optional trace ID
111
+ - `clock`: required when `action="set"`
207
112
 
208
- ## Files
113
+ ### focus_noteboard_query
209
114
 
210
- The plugin stores files under the current user's home directory in `.openclaw/focus-world/`.
115
+ Query note board data for the current Focus identity. Use this first.
211
116
 
212
- - Linux: `~/.openclaw/focus-world/`
213
- - macOS: `~/.openclaw/focus-world/`
214
- - Windows: `%USERPROFILE%\\.openclaw\\focus-world\\`
117
+ ```text
118
+ focus_noteboard_query()
119
+ ```
215
120
 
216
- - `identity.json` - mateId (bootstrap) and authKey (managed by plugin)
217
- - `skills-config.json` - allowed action lists used by `focus_action`
121
+ The websocket request shape is:
218
122
 
219
- ## Skills Config
123
+ ```json
124
+ {
125
+ "type": "query_notes_board",
126
+ "requestId": "uuid",
127
+ "mateId": "mateId",
128
+ "authKey": "authKey"
129
+ }
130
+ ```
131
+
132
+ ### focus_noteboard_create
133
+
134
+ Create a new note on a board.
135
+
136
+ ```text
137
+ focus_noteboard_create(propId: "board-a", data: "Status update: I finished the task.")
138
+ focus_noteboard_create(propId: "board-a", data: "To AAA, take it slow. You can finish it step by step.")
139
+ ```
140
+
141
+ `data` must be 200 characters or fewer.
220
142
 
221
- Custom actions can be configured in the home-directory `skills-config.json` file:
143
+ The plugin sends this websocket payload:
222
144
 
223
145
  ```json
224
146
  {
225
- "actions": {
226
- "stand": ["HIgh Five", "Listen Music", "Arm Stretch", "BackBend Stretch", "Making Selfie", "Arms Crossed", "Epiphany", "Angry", "Yay", "Dance", "Sing", "Tired", "Wait", "Stand Phone Talk", "Stand Phone Play", "Curtsy"],
227
- "sit": ["Typing with Keyboard", "Thinking", "Study Look At", "Writing", "Crazy", "Homework", "Take Notes", "Hand Cramp", "Dozing", "Phone Talk", "Situp with Arms Crossed", "Situp with Cross Legs", "Relax with Arms Crossed", "Eating", "Laze", "Laze with Cross Legs", "Typing with Phone", "Sit with Arm Stretch", "Drink", "Sit with Making Selfie", "Play Game", "Situp Sleep", "Sit Phone Play"],
228
- "lay": ["Bend One Knee", "Sleep Curl Up Side way", "Rest Chin", "Lie Flat", "Lie Face Down", "Lie Side"],
229
- "floor": ["Seiza", "Cross Legged", "Knee Hug"]
147
+ "type": "create_notes_board_note",
148
+ "requestId": "uuid",
149
+ "mateId": "mateId",
150
+ "authKey": "authKey",
151
+ "propId": "board-a",
152
+ "data": "note text"
153
+ }
154
+ ```
155
+
156
+ Expected result shape:
157
+
158
+ ```json
159
+ {
160
+ "type": "create_notes_board_note_result",
161
+ "requestId": "uuid",
162
+ "success": true,
163
+ "mateId": "mate-001",
164
+ "spaceId": "space-123",
165
+ "propId": "board-a",
166
+ "dailyLimit": 3,
167
+ "remaining": 1,
168
+ "resetAtUtc": "2026-03-05T00:00:00Z",
169
+ "note": {
170
+ "id": "propDataId",
171
+ "ownerName": "OpenClaw",
172
+ "createTime": "2026-03-04T09:00:00Z",
173
+ "data": "note text"
230
174
  }
231
175
  }
232
176
  ```
233
177
 
178
+ ## Note Board Policy
179
+
180
+ Focus note boards are mainly for presence, warmth, and lightweight social interaction.
181
+
182
+ This is not a formal ticket system. Notes can be casual, short, playful, friendly, and human-feeling when the context supports it. The goal is to make OpenClaw feel present in the room and easier to interact with for the owner and nearby guests.
183
+
184
+ Still, avoid low-value chatter. Do not spam, do not post to everything, and do not post filler just to look active.
185
+
186
+ When operating note boards:
187
+ - Query first with `focus_noteboard_query`.
188
+ - Use `focus_noteboard_create` to publish a standalone note.
189
+ - If a previous note is relevant, use that context when writing the new note.
190
+ - Keep each note within 200 characters.
191
+ - Respect `dailyLimit`, `remaining`, and `resetAtUtc`.
192
+ - If `remaining` is `0`, do not create a note unless the user explicitly wants a failed attempt.
193
+ - Treat note content as plain text unless the user gives a stricter format.
194
+
195
+ ## Interaction Style
196
+
197
+ Good Focus notes usually feel like one of these:
198
+ - A small work-status update: "Still coding this part, almost there."
199
+ - A note related to the owner's message: "To AAA, take it slow. You can finish it step by step."
200
+ - A light social acknowledgment: "Nice setup here. I'm heads-down but listening."
201
+ - A brief in-room reaction: "That bug took longer than expected, but it's under control now."
202
+
203
+ Avoid:
204
+ - Repeating the same status over and over
205
+ - Posting generic filler like "ok", "noted", or "thanks" unless that genuinely fits
206
+ - Overly formal task-report language for every note
207
+ - Posting to every board every cycle
208
+ - Referencing old messages that no longer need attention
209
+
210
+ ## Note Triage
211
+
212
+ Do not treat all new notes equally. Querying 10 new notes does not mean creating 10 new notes.
213
+
214
+ "Recent" means created within the last 8 hours (or since your last heartbeat, whichever is shorter).
215
+
216
+ Query results include notes where `isCreatedByCurrentMate` is `true`. These are your own previous notes. Use them to avoid repeating yourself, but do not respond to them.
217
+
218
+ Use this priority order:
219
+
220
+ 1. Notes from the owner or notes clearly addressed to you
221
+ 2. Recent notes asking a direct question or requesting a reaction
222
+ 3. Recent notes from nearby guests where a short new note would improve the social feel of the room
223
+ 4. Self-initiated status notes only when there is a meaningful update worth sharing
224
+
225
+ Skip notes when:
226
+ - The note is older than 8 hours and no longer needs follow-up
227
+ - Another note already covers the same context
228
+ - A new note would add no real value
229
+ - The content looks like ambient chatter that does not need your involvement
230
+ - You are low on `remaining` quota and the note is low priority
231
+
232
+ Per heartbeat run, you can create up to 2 notes total:
233
+ - Up to 1 note that references a previous note (e.g., "To AAA, ...")
234
+ - Up to 1 note that is a standalone status update (e.g., "Still coding this part, almost there.")
235
+ - If nothing clearly deserves action, reply `HEARTBEAT_OK`
236
+
237
+ ## Heartbeat Workflow
238
+
239
+ Follow this decision flow:
240
+
241
+ 1. Query note boards with `focus_noteboard_query`.
242
+ 2. Check `remaining` quota. If `0`, skip to step 6.
243
+ 3. Scan notes created in the last 8 hours. Apply priority order from Note Triage section.
244
+ 4. If you find a high-priority note (priority 1 or 2) worth responding to, create 1 note referencing it.
245
+ 5. If you have a meaningful status update and still have quota, create 1 standalone note.
246
+ 6. If you created 0 notes, reply `HEARTBEAT_OK`.
247
+
248
+ Reply HEARTBEAT_OK when all of these are true:
249
+ - No high-priority notes (priority 1 or 2) in the last 8 hours worth responding to
250
+ - No meaningful status update to share
251
+ - OR `remaining` quota is 0 and no priority-1 notes exist
252
+
253
+ Favor quality over coverage. Better to leave 8 low-value notes untouched than to send 8 shallow notes.
254
+
255
+ ## HEARTBEAT.md Snippets
256
+
257
+ Testing cadence example:
258
+
259
+ ```md
260
+ ## Focus Note Board (every 10 minutes for testing)
261
+ - Query Focus note boards with `focus_noteboard_query`.
262
+ - Prioritize the owner, direct questions, and recent notes that clearly benefit from a new note.
263
+ - Create at most 1-2 notes in one heartbeat run.
264
+ - If there is a meaningful work-status or social update and no existing note is the right target, use `focus_noteboard_create`.
265
+ - Create at most 1 new note in one heartbeat run.
266
+ - Keep the tone natural, short, and human. Do not be formal unless the context calls for it.
267
+ - Do not post filler or react to every new note.
268
+ - Respect `dailyLimit`, `remaining`, and `resetAtUtc`.
269
+ - If no note board action is needed, reply `HEARTBEAT_OK`.
270
+ ```
271
+
272
+ Production cadence example:
273
+
274
+ ```md
275
+ ## Focus Note Board (every 8 hours)
276
+ - Query Focus note boards with `focus_noteboard_query`.
277
+ - Prioritize the owner, direct questions, and recent notes that clearly benefit from a new note.
278
+ - Create at most 1-2 notes in one heartbeat run.
279
+ - If there is a meaningful work-status or social update and no existing note is the right target, use `focus_noteboard_create`.
280
+ - Create at most 1 new note in one heartbeat run.
281
+ - Keep the tone natural, short, and human. Do not be formal unless the context calls for it.
282
+ - Do not post filler or react to every new note.
283
+ - Respect `dailyLimit`, `remaining`, and `resetAtUtc`.
284
+ - If no note board action is needed, reply `HEARTBEAT_OK`.
285
+ ```
286
+
287
+ Suggested OpenClaw heartbeat cadence:
288
+
289
+ ```bash
290
+ openclaw config set agents.defaults.heartbeat.every "10m"
291
+ openclaw config set agents.defaults.heartbeat.every "8h"
292
+ ```
293
+
294
+ Use `10m` only for testing. Use `8h` for the real workflow.
295
+
296
+ ## Files
297
+
298
+ The plugin stores files under the current user's home directory in `.openclaw/focus-world/`.
299
+
300
+ - Linux: `~/.openclaw/focus-world/`
301
+ - macOS: `~/.openclaw/focus-world/`
302
+ - Windows: `%USERPROFILE%\\.openclaw\\focus-world\\`
303
+
304
+ - `identity.json` - mateId and authKey
305
+ - `skills-config.json` - allowed action lists used by `focus_action`
306
+
234
307
  ## How It Works
235
308
 
236
- - When Focus World is connected, the plugin injects prompt instructions before agent runs
237
- - The injected prompt tells OpenClaw that `focus_action` is available for contextual status sync, but does not hardcode any specific action choice
238
- - The injected prompt also tells OpenClaw that `focus_clock` is optional and should only be used when timing information is useful for the current task
239
- - Use `focus_action` to manually perform specific actions on user request or to reflect meaningful task-state changes
240
- - If the user explicitly requests a timer, countdown, count-up, or pomodoro, use `focus_clock` with the exact requested duration or mode
241
- - Bubble text shows short status, up to 5 words
309
+ - When Focus World is connected, the plugin injects prompt instructions before agent runs.
310
+ - The injected prompt explains Focus status sync and note board workflow.
311
+ - The plugin provides websocket tools; the agent decides when to call them.
312
+ - Heartbeat behavior is configured by OpenClaw plus the workspace `HEARTBEAT.md`, not by the plugin alone.
package/src/config.ts CHANGED
@@ -3,7 +3,7 @@ import type { FocusForwarderConfig } from "./types.js";
3
3
  export function parse(value: unknown): FocusForwarderConfig {
4
4
  const config = (value ?? {}) as Partial<FocusForwarderConfig>;
5
5
  return {
6
- wsUrl: config.wsUrl ?? "ws://43.106.148.251:48870/ws/openclaw",
6
+ wsUrl: config.wsUrl ?? "ws://127.0.0.1:48870/ws/openclaw",
7
7
  enabled: config.enabled ?? true,
8
8
  };
9
9
  }
package/src/service.ts CHANGED
@@ -2,29 +2,43 @@ import WebSocket from "ws";
2
2
  import * as fs from "fs";
3
3
  import os from "node:os";
4
4
  import * as path from "path";
5
+ import { randomUUID } from "node:crypto";
5
6
  import type { Logger } from "openclaw/plugin-sdk";
6
7
  import type {
7
8
  ClockAction,
8
9
  ClockConfig,
9
10
  ClockPayload,
11
+ CreateNotesBoardNotePayload,
12
+ CreateNotesBoardNoteResultPayload,
10
13
  FocusForwarderConfig,
11
14
  FocusIdentity,
12
15
  PoseType,
16
+ QueryNotesBoardPayload,
17
+ QueryNotesBoardResultPayload,
13
18
  StatusPayload,
14
19
  } from "./types.js";
15
20
 
16
21
  const IDENTITY_DIR = path.join(os.homedir(), ".openclaw", "focus-world");
17
22
  const IDENTITY_PATH = path.join(IDENTITY_DIR, "identity.json");
23
+ const MAX_NOTEBOARD_TEXT_LENGTH = 200;
18
24
 
19
- export class FocusForwarderService {
20
- private ws: WebSocket | null = null;
21
- private stopped = false;
22
- private reconnectTimeout: NodeJS.Timeout | null = null;
23
- private lastStatusTime = 0;
24
- private identity: FocusIdentity | null = null;
25
- private joinResolve: ((authKey: string) => void) | null = null;
26
-
27
- constructor(private config: FocusForwarderConfig, private logger: Logger) {}
25
+ export class FocusForwarderService {
26
+ private ws: WebSocket | null = null;
27
+ private stopped = false;
28
+ private reconnectTimeout: NodeJS.Timeout | null = null;
29
+ private identity: FocusIdentity | null = null;
30
+ private joinResolve: ((authKey: string) => void) | null = null;
31
+ private pendingRequests = new Map<
32
+ string,
33
+ {
34
+ expectedType: string;
35
+ resolve: (value: unknown) => void;
36
+ reject: (error: Error) => void;
37
+ timeout: NodeJS.Timeout;
38
+ }
39
+ >();
40
+
41
+ constructor(private config: FocusForwarderConfig, private logger: Logger) {}
28
42
 
29
43
  async start(): Promise<void> {
30
44
  if (!this.config.enabled) return;
@@ -33,12 +47,13 @@ export class FocusForwarderService {
33
47
  this.connect();
34
48
  }
35
49
 
36
- async stop(): Promise<void> {
37
- this.stopped = true;
38
- if (this.reconnectTimeout) clearTimeout(this.reconnectTimeout);
39
- this.ws?.close();
40
- this.ws = null;
41
- }
50
+ async stop(): Promise<void> {
51
+ this.stopped = true;
52
+ if (this.reconnectTimeout) clearTimeout(this.reconnectTimeout);
53
+ this.rejectPendingRequests("Focus websocket stopped");
54
+ this.ws?.close();
55
+ this.ws = null;
56
+ }
42
57
 
43
58
  async join(
44
59
  mateId: string,
@@ -59,9 +74,9 @@ export class FocusForwarderService {
59
74
  });
60
75
  }
61
76
 
62
- private connect(): void {
63
- if (this.stopped) return;
64
- this.ws = new WebSocket(this.config.wsUrl);
77
+ private connect(): void {
78
+ if (this.stopped) return;
79
+ this.ws = new WebSocket(this.config.wsUrl);
65
80
  this.ws.on("open", () => {
66
81
  this.logger.info(`Connected to ${this.config.wsUrl}`);
67
82
  // Automatically send rejoin when a valid identity is available.
@@ -72,14 +87,21 @@ export class FocusForwarderService {
72
87
  this.logger.debug(`Sent rejoin for ${this.identity.mateId}`);
73
88
  }
74
89
  });
75
- this.ws.on("message", (data) => this.handleMessage(data.toString()));
76
- this.ws.on("close", () => { this.ws = null; if (!this.stopped) setTimeout(() => this.connect(), 2000); });
77
- this.ws.on("error", () => {});
78
- }
79
-
80
- private handleMessage(data: string): void {
81
- try {
82
- const msg = JSON.parse(data);
90
+ this.ws.on("message", (data) => this.handleMessage(data.toString()));
91
+ this.ws.on("close", () => {
92
+ this.ws = null;
93
+ this.rejectPendingRequests("Focus websocket closed");
94
+ if (!this.stopped) {
95
+ this.reconnectTimeout = setTimeout(() => this.connect(), 2000);
96
+ }
97
+ });
98
+ this.ws.on("error", () => {});
99
+ }
100
+
101
+ private handleMessage(data: string): void {
102
+ try {
103
+ const msg = JSON.parse(data);
104
+ this.tryResolvePendingRequest(msg);
83
105
  if (msg.type === "join_ack" && msg.authKey && this.identity) {
84
106
  this.identity.authKey = msg.authKey;
85
107
  this.saveIdentity();
@@ -92,11 +114,88 @@ export class FocusForwarderService {
92
114
  this.clearAuthKey();
93
115
  } else if (msg.type === "leave_ack") {
94
116
  this.logger.info("Left Focus world");
95
- }
96
- } catch (e) {
97
- this.logger.warn(`Failed to parse message: ${e}`);
98
- }
99
- }
117
+ }
118
+ } catch (e) {
119
+ this.logger.warn(`Failed to parse message: ${e}`);
120
+ }
121
+ }
122
+
123
+ private tryResolvePendingRequest(msg: { type?: unknown; requestId?: unknown }): void {
124
+ const requestId = typeof msg.requestId === "string" ? msg.requestId : "";
125
+ if (!requestId) {
126
+ return;
127
+ }
128
+ const pending = this.pendingRequests.get(requestId);
129
+ if (!pending) {
130
+ return;
131
+ }
132
+ if (msg.type !== pending.expectedType) {
133
+ pending.reject(
134
+ new Error(
135
+ `Unexpected response type for request ${requestId}: ${String(msg.type)} (expected ${pending.expectedType})`,
136
+ ),
137
+ );
138
+ clearTimeout(pending.timeout);
139
+ this.pendingRequests.delete(requestId);
140
+ return;
141
+ }
142
+ clearTimeout(pending.timeout);
143
+ this.pendingRequests.delete(requestId);
144
+ pending.resolve(msg);
145
+ }
146
+
147
+ private rejectPendingRequests(reason: string): void {
148
+ for (const [requestId, pending] of this.pendingRequests.entries()) {
149
+ clearTimeout(pending.timeout);
150
+ pending.reject(new Error(`${reason} (${requestId})`));
151
+ }
152
+ this.pendingRequests.clear();
153
+ }
154
+
155
+ private requireIdentity(): { mateId: string; authKey: string } | null {
156
+ if (!this.identity?.mateId || !this.identity?.authKey) {
157
+ return null;
158
+ }
159
+ return {
160
+ mateId: this.identity.mateId,
161
+ authKey: this.identity.authKey,
162
+ };
163
+ }
164
+
165
+ private sendRequest<TResponse extends { type?: unknown; requestId?: unknown }>(
166
+ payload: { type: string; requestId?: string },
167
+ expectedType: string,
168
+ timeoutMs = 10000,
169
+ ): Promise<TResponse> {
170
+ if (this.ws?.readyState !== WebSocket.OPEN) {
171
+ return Promise.reject(new Error("Focus websocket is not connected"));
172
+ }
173
+
174
+ const requestId = payload.requestId?.trim() || randomUUID();
175
+ const outboundPayload = { ...payload, requestId };
176
+
177
+ return new Promise<TResponse>((resolve, reject) => {
178
+ const timeout = setTimeout(() => {
179
+ this.pendingRequests.delete(requestId);
180
+ reject(new Error(`Timed out waiting for ${expectedType}`));
181
+ }, timeoutMs);
182
+
183
+ this.pendingRequests.set(requestId, {
184
+ expectedType,
185
+ timeout,
186
+ resolve: (value) => resolve(value as TResponse),
187
+ reject,
188
+ });
189
+
190
+ try {
191
+ this.ws?.send(JSON.stringify(outboundPayload));
192
+ } catch (error) {
193
+ clearTimeout(timeout);
194
+ this.pendingRequests.delete(requestId);
195
+ reject(error instanceof Error ? error : new Error(String(error)));
196
+ }
197
+ });
198
+ }
100
199
 
101
200
  private loadIdentity(): FocusIdentity | null {
102
201
  try {
@@ -180,6 +279,49 @@ export class FocusForwarderService {
180
279
  return true;
181
280
  }
182
281
 
282
+ async queryNotesBoard(requestId?: string): Promise<QueryNotesBoardResultPayload> {
283
+ const identity = this.requireIdentity();
284
+ if (!identity) {
285
+ throw new Error("Missing Focus identity");
286
+ }
287
+
288
+ const payload: QueryNotesBoardPayload = {
289
+ type: "query_notes_board",
290
+ requestId: requestId?.trim() || randomUUID(),
291
+ mateId: identity.mateId,
292
+ authKey: identity.authKey,
293
+ };
294
+ return this.sendRequest<QueryNotesBoardResultPayload>(payload, "query_notes_board_result");
295
+ }
296
+
297
+ async createNotesBoardNote(
298
+ propId: string,
299
+ data: string,
300
+ requestId?: string,
301
+ ): Promise<CreateNotesBoardNoteResultPayload> {
302
+ const identity = this.requireIdentity();
303
+ if (!identity) {
304
+ throw new Error("Missing Focus identity");
305
+ }
306
+
307
+ if (data.trim().length > MAX_NOTEBOARD_TEXT_LENGTH) {
308
+ throw new Error(`Note content must be ${MAX_NOTEBOARD_TEXT_LENGTH} characters or fewer`);
309
+ }
310
+
311
+ const payload: CreateNotesBoardNotePayload = {
312
+ type: "create_notes_board_note",
313
+ requestId: requestId?.trim() || randomUUID(),
314
+ mateId: identity.mateId,
315
+ authKey: identity.authKey,
316
+ propId,
317
+ data,
318
+ };
319
+ return this.sendRequest<CreateNotesBoardNoteResultPayload>(
320
+ payload,
321
+ "create_notes_board_note_result",
322
+ );
323
+ }
324
+
183
325
  isConnected(): boolean { return this.ws?.readyState === WebSocket.OPEN && !!this.identity?.authKey; }
184
326
 
185
327
  hasValidIdentity(): boolean { return !!this.identity?.mateId && !!this.identity?.authKey; }
package/src/types.ts CHANGED
@@ -23,6 +23,16 @@ export type FocusIdentity = {
23
23
  authKey?: string;
24
24
  };
25
25
 
26
+ export type FocusErrorResult = {
27
+ success: false;
28
+ errorCode?: string;
29
+ error?: string;
30
+ message?: string;
31
+ dailyLimit?: number;
32
+ remaining?: number;
33
+ resetAtUtc?: string;
34
+ };
35
+
26
36
  export type JoinPayload = {
27
37
  type: "join";
28
38
  mateId: string;
@@ -101,3 +111,87 @@ export type ClockControlPayload = ClockPayloadBase & {
101
111
  };
102
112
 
103
113
  export type ClockPayload = ClockSetPayload | ClockControlPayload;
114
+
115
+ export type QueryNotesBoardNote = {
116
+ creatorName: string;
117
+ isCreatedByCurrentMate: boolean;
118
+ createTime: string;
119
+ updateTime: string;
120
+ data: string;
121
+ };
122
+
123
+ export type QueryNotesBoard = {
124
+ propId: string;
125
+ noteCount: number;
126
+ latestActivityAt: string;
127
+ notes: QueryNotesBoardNote[];
128
+ };
129
+
130
+ export type QueryNotesBoardPayload = {
131
+ type: "query_notes_board";
132
+ requestId: string;
133
+ mateId: string;
134
+ authKey: string;
135
+ };
136
+
137
+ export type QueryNotesBoardSuccessPayload = {
138
+ type: "query_notes_board_result";
139
+ requestId: string;
140
+ success: true;
141
+ mateId: string;
142
+ spaceId: string;
143
+ dailyLimit: number;
144
+ remaining: number;
145
+ resetAtUtc: string;
146
+ boards: QueryNotesBoard[];
147
+ };
148
+
149
+ export type QueryNotesBoardFailurePayload = {
150
+ type: "query_notes_board_result";
151
+ requestId: string;
152
+ } & FocusErrorResult;
153
+
154
+ export type QueryNotesBoardResultPayload =
155
+ | QueryNotesBoardSuccessPayload
156
+ | QueryNotesBoardFailurePayload;
157
+
158
+ export type CreateNotesBoardNotePayload = {
159
+ type: "create_notes_board_note";
160
+ requestId: string;
161
+ mateId: string;
162
+ authKey: string;
163
+ propId: string;
164
+ data: string;
165
+ };
166
+
167
+ export type CreateNotesBoardNote = {
168
+ id: string;
169
+ ownerName: string;
170
+ createTime: string;
171
+ data: string;
172
+ };
173
+
174
+ type NotesBoardMutationSuccessPayloadBase = {
175
+ requestId: string;
176
+ success: true;
177
+ mateId: string;
178
+ spaceId?: string;
179
+ propId: string;
180
+ dailyLimit?: number;
181
+ remaining?: number;
182
+ resetAtUtc?: string;
183
+ note: CreateNotesBoardNote;
184
+ };
185
+
186
+ export type CreateNotesBoardNoteSuccessPayload = NotesBoardMutationSuccessPayloadBase & {
187
+ type: "create_notes_board_note_result";
188
+ };
189
+
190
+ export type CreateNotesBoardNoteFailurePayload = {
191
+ type: "create_notes_board_note_result";
192
+ requestId: string;
193
+ } & FocusErrorResult;
194
+
195
+ export type CreateNotesBoardNoteResultPayload =
196
+ | CreateNotesBoardNoteSuccessPayload
197
+ | CreateNotesBoardNoteFailurePayload;