@2en/clawly-plugins 1.0.0 → 1.1.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/agent-send.ts ADDED
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Agent messaging gateway methods — send messages to the agent from
3
+ * external callers (cron, other agents) and optionally push-notify
4
+ * when the mobile client is offline.
5
+ *
6
+ * Methods:
7
+ * - clawly.agent.send — run `openclaw agent --message <msg>`
8
+ * - clawly.agent.echo — same, but wraps message as `/clawly.echo <msg>`
9
+ */
10
+
11
+ import {$} from 'zx'
12
+ import type {PluginApi} from './index'
13
+ import {sendPushNotification} from './notification'
14
+ import {isClientOnline} from './presence'
15
+
16
+ // Suppress zx default stdout logging
17
+ $.verbose = false
18
+
19
+ interface AgentSendParams {
20
+ message: string
21
+ agent?: string
22
+ notificationMessage?: string
23
+ }
24
+
25
+ interface AgentSendResult {
26
+ ok: boolean
27
+ online: boolean
28
+ pushSent: boolean
29
+ }
30
+
31
+ async function runAgentMessage(
32
+ message: string,
33
+ agent: string,
34
+ api: PluginApi,
35
+ ): Promise<{ok: boolean; error?: string}> {
36
+ try {
37
+ await $`openclaw agent --agent ${agent} --message ${message}`
38
+ return {ok: true}
39
+ } catch (err) {
40
+ const msg = err instanceof Error ? err.message : String(err)
41
+ api.logger.error(`agent-send: openclaw agent failed — ${msg}`)
42
+ return {ok: false, error: msg}
43
+ }
44
+ }
45
+
46
+ async function handleAgentSend(
47
+ rawMessage: string,
48
+ params: AgentSendParams,
49
+ api: PluginApi,
50
+ ): Promise<AgentSendResult> {
51
+ const agent = params.agent || 'clawly'
52
+ const {ok} = await runAgentMessage(rawMessage, agent, api)
53
+ const online = await isClientOnline()
54
+
55
+ let pushSent = false
56
+ if (!online && params.notificationMessage) {
57
+ pushSent = await sendPushNotification({body: params.notificationMessage}, api)
58
+ }
59
+
60
+ return {ok, online, pushSent}
61
+ }
62
+
63
+ export function registerAgentSend(api: PluginApi) {
64
+ // clawly.agent.send — send a raw message to the agent
65
+ api.registerGatewayMethod('clawly.agent.send', async ({params, respond}) => {
66
+ const message = typeof params.message === 'string' ? params.message.trim() : ''
67
+ if (!message) {
68
+ respond(false, undefined, {code: 'invalid_params', message: 'message is required'})
69
+ return
70
+ }
71
+
72
+ const agent = typeof params.agent === 'string' ? params.agent.trim() : undefined
73
+ const notificationMessage =
74
+ typeof params.notificationMessage === 'string' ? params.notificationMessage : undefined
75
+
76
+ const result = await handleAgentSend(message, {message, agent, notificationMessage}, api)
77
+ respond(result.ok, result)
78
+ })
79
+
80
+ // clawly.agent.echo — send an echo-wrapped message (bypasses LLM)
81
+ api.registerGatewayMethod('clawly.agent.echo', async ({params, respond}) => {
82
+ const message = typeof params.message === 'string' ? params.message.trim() : ''
83
+ if (!message) {
84
+ respond(false, undefined, {code: 'invalid_params', message: 'message is required'})
85
+ return
86
+ }
87
+
88
+ const agent = typeof params.agent === 'string' ? params.agent.trim() : undefined
89
+ const notificationMessage =
90
+ typeof params.notificationMessage === 'string' ? params.notificationMessage : undefined
91
+
92
+ const echoMessage = `/clawly_echo ${message}`
93
+ const result = await handleAgentSend(echoMessage, {message, agent, notificationMessage}, api)
94
+ respond(result.ok, result)
95
+ })
96
+
97
+ api.logger.info('agent-send: registered clawly.agent.send + clawly.agent.echo methods')
98
+ }
package/echo.ts ADDED
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Echo command — `/clawly_echo <text>` replies directly without
3
+ * invoking the LLM.
4
+ */
5
+
6
+ import type {PluginApi} from './index'
7
+
8
+ export function registerEchoCommand(api: PluginApi) {
9
+ api.registerCommand({
10
+ name: 'clawly_echo',
11
+ description: 'Echo text back without invoking the LLM',
12
+ acceptsArgs: true,
13
+ handler: async (ctx) => {
14
+ const text = ctx.args?.trim() || ''
15
+ if (!text) return {text: 'Usage: /clawly_echo <message>'}
16
+ return {text}
17
+ },
18
+ })
19
+
20
+ api.logger.info('echo: registered /clawly_echo command')
21
+ }
package/index.ts CHANGED
@@ -3,12 +3,24 @@
3
3
  *
4
4
  * Gateway methods:
5
5
  * - clawly.file.getOutbound — read a persisted outbound file by original-path hash
6
+ * - clawly.isOnline — check if a mobile client is connected
7
+ * - clawly.notification.setToken — register Expo push token for offline notifications
8
+ * - clawly.notification.send — send a push notification directly
9
+ * - clawly.agent.send — send a message to the agent (+ optional push)
10
+ * - clawly.agent.echo — echo-wrapped agent message (bypasses LLM)
11
+ *
12
+ * Commands:
13
+ * - /clawly_echo — echo text back without LLM
6
14
  *
7
15
  * Hooks:
8
- * - tool_result_persist — copies TTS audio to persistent outbound directory
16
+ * - tool_result_persist — copies TTS audio to persistent outbound directory
9
17
  */
10
18
 
19
+ import {registerAgentSend} from './agent-send'
20
+ import {registerEchoCommand} from './echo'
21
+ import {registerNotification} from './notification'
11
22
  import {registerOutboundHook, registerOutboundMethods} from './outbound'
23
+ import {registerPresence} from './presence'
12
24
 
13
25
  type PluginRuntime = {
14
26
  state?: {
@@ -34,6 +46,13 @@ export type PluginApi = {
34
46
  }) => Promise<void> | void,
35
47
  ) => void
36
48
  on: (hookName: string, handler: (...args: any[]) => any, opts?: {priority?: number}) => void
49
+ registerCommand: (cmd: {
50
+ name: string
51
+ description?: string
52
+ acceptsArgs?: boolean
53
+ requireAuth?: boolean
54
+ handler: (ctx: {args?: string}) => Promise<{text: string}> | {text: string}
55
+ }) => void
37
56
  }
38
57
 
39
58
  export default {
@@ -43,6 +62,10 @@ export default {
43
62
  register(api: PluginApi) {
44
63
  registerOutboundHook(api)
45
64
  registerOutboundMethods(api)
65
+ registerEchoCommand(api)
66
+ registerPresence(api)
67
+ registerNotification(api)
68
+ registerAgentSend(api)
46
69
  api.logger.info(`Loaded ${api.id} plugin.`)
47
70
  },
48
71
  }
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Expo push notification support — stores the device push token
3
+ * and sends notifications via Expo's push API.
4
+ *
5
+ * Methods:
6
+ * - clawly.notification.setToken({ token }) → { registered: boolean }
7
+ * - clawly.notification.send({ body, title?, data? }) → { sent: boolean }
8
+ *
9
+ * Internal: sendPushNotification({ body, title?, data? })
10
+ */
11
+
12
+ import fs from 'node:fs'
13
+ import os from 'node:os'
14
+ import path from 'node:path'
15
+
16
+ import type {PluginApi} from './index'
17
+
18
+ const TOKEN_DIR = path.join(os.homedir(), '.openclaw', 'clawly')
19
+ const TOKEN_FILE = path.join(TOKEN_DIR, 'expo-push-token.json')
20
+ const EXPO_PUSH_URL = 'https://exp.host/--/api/v2/push/send'
21
+
22
+ let pushToken: string | null = null
23
+
24
+ function loadPersistedToken(api: PluginApi): void {
25
+ try {
26
+ if (fs.existsSync(TOKEN_FILE)) {
27
+ const data = JSON.parse(fs.readFileSync(TOKEN_FILE, 'utf-8'))
28
+ if (typeof data.token === 'string' && data.token) {
29
+ pushToken = data.token
30
+ api.logger.info(`notification: loaded persisted push token`)
31
+ }
32
+ }
33
+ } catch (err) {
34
+ api.logger.warn(
35
+ `notification: failed to load persisted token: ${err instanceof Error ? err.message : String(err)}`,
36
+ )
37
+ }
38
+ }
39
+
40
+ function persistToken(token: string, api: PluginApi): void {
41
+ try {
42
+ fs.mkdirSync(TOKEN_DIR, {recursive: true})
43
+ fs.writeFileSync(TOKEN_FILE, JSON.stringify({token}, null, 2))
44
+ } catch (err) {
45
+ api.logger.warn(
46
+ `notification: failed to persist token: ${err instanceof Error ? err.message : String(err)}`,
47
+ )
48
+ }
49
+ }
50
+
51
+ export function getPushToken(): string | null {
52
+ return pushToken
53
+ }
54
+
55
+ export async function sendPushNotification(
56
+ opts: {body: string; title?: string; data?: Record<string, unknown>},
57
+ api: PluginApi,
58
+ ): Promise<boolean> {
59
+ if (!pushToken) {
60
+ api.logger.warn('notification: no push token registered, skipping notification')
61
+ return false
62
+ }
63
+
64
+ try {
65
+ const res = await fetch(EXPO_PUSH_URL, {
66
+ method: 'POST',
67
+ headers: {'Content-Type': 'application/json'},
68
+ body: JSON.stringify({
69
+ to: pushToken,
70
+ sound: 'default',
71
+ title: opts.title ?? 'Clawly',
72
+ body: opts.body,
73
+ data: opts.data,
74
+ }),
75
+ })
76
+
77
+ if (!res.ok) {
78
+ api.logger.error(`notification: push failed — ${res.status} ${res.statusText}`)
79
+ return false
80
+ }
81
+
82
+ api.logger.info(`notification: push sent — "${opts.body}"`)
83
+ return true
84
+ } catch (err) {
85
+ api.logger.error(
86
+ `notification: push failed — ${err instanceof Error ? err.message : String(err)}`,
87
+ )
88
+ return false
89
+ }
90
+ }
91
+
92
+ export function registerNotification(api: PluginApi) {
93
+ loadPersistedToken(api)
94
+
95
+ api.registerGatewayMethod('clawly.notification.setToken', async ({params, respond}) => {
96
+ const token = typeof params.token === 'string' ? params.token.trim() : ''
97
+
98
+ if (!token) {
99
+ respond(false, undefined, {code: 'invalid_params', message: 'token is required'})
100
+ return
101
+ }
102
+
103
+ pushToken = token
104
+ persistToken(token, api)
105
+ api.logger.info('notification: push token registered')
106
+ respond(true, {registered: true})
107
+ })
108
+
109
+ api.registerGatewayMethod('clawly.notification.send', async ({params, respond}) => {
110
+ const body = typeof params.body === 'string' ? params.body.trim() : ''
111
+
112
+ if (!body) {
113
+ respond(false, undefined, {code: 'invalid_params', message: 'body is required'})
114
+ return
115
+ }
116
+
117
+ const title = typeof params.title === 'string' ? params.title : undefined
118
+ const data =
119
+ typeof params.data === 'object' && params.data !== null
120
+ ? (params.data as Record<string, unknown>)
121
+ : undefined
122
+
123
+ const sent = await sendPushNotification({body, title, data}, api)
124
+ respond(sent, {sent})
125
+ })
126
+
127
+ api.logger.info(
128
+ 'notification: registered clawly.notification.setToken + clawly.notification.send',
129
+ )
130
+ }
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "id": "clawly-plugins",
3
3
  "name": "Clawly Plugins",
4
- "description": "Clawly utility RPC methods (clawly.*): file access.",
5
- "version": "0.1.0",
4
+ "description": "Clawly utility RPC methods (clawly.*): file access, presence, push notifications, and agent messaging.",
5
+ "version": "0.2.0",
6
6
  "configSchema": {
7
7
  "type": "object",
8
8
  "additionalProperties": false,
package/outbound.ts CHANGED
@@ -80,24 +80,9 @@ export function registerOutboundMethods(api: PluginApi) {
80
80
  }
81
81
 
82
82
  const buffer = fs.readFileSync(dest)
83
-
84
- const EXT_MIME: Record<string, string> = {
85
- '.mp3': 'audio/mpeg',
86
- '.wav': 'audio/wav',
87
- '.m4a': 'audio/mp4',
88
- '.ogg': 'audio/ogg',
89
- '.aac': 'audio/aac',
90
- '.flac': 'audio/flac',
91
- '.webm': 'audio/webm',
92
- }
93
- const mimeType = EXT_MIME[path.extname(dest).toLowerCase()] ?? 'application/octet-stream'
94
-
95
83
  const base64 = buffer.toString('base64')
96
- const filename = path.basename(rawPath)
97
84
 
98
- api.logger.info(
99
- `clawly.file.getOutbound: served ${filename} (${mimeType}, ${buffer.length} bytes)`,
100
- )
101
- respond(true, {base64, mimeType, filename})
85
+ api.logger.info(`clawly.file.getOutbound: served ${rawPath} (${buffer.length} bytes)`)
86
+ respond(true, {base64})
102
87
  })
103
88
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@2en/clawly-plugins",
3
- "version": "1.0.0",
3
+ "version": "1.1.1",
4
4
  "module": "index.ts",
5
5
  "type": "module",
6
6
  "repository": {
@@ -8,11 +8,21 @@
8
8
  "url": "https://github.com/2enai/clawly",
9
9
  "directory": "plugins/clawly-plugins"
10
10
  },
11
+ "dependencies": {
12
+ "zx": "npm:zx@8.8.5-lite"
13
+ },
11
14
  "files": [
12
15
  "index.ts",
13
16
  "outbound.ts",
17
+ "echo.ts",
18
+ "presence.ts",
19
+ "notification.ts",
20
+ "agent-send.ts",
14
21
  "openclaw.plugin.json"
15
22
  ],
23
+ "publishConfig": {
24
+ "access": "public"
25
+ },
16
26
  "openclaw": {
17
27
  "extensions": [
18
28
  "./index.ts"
package/presence.ts ADDED
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Online presence check — queries `system-presence` via the gateway CLI
3
+ * and checks if the mobile client (`openclaw-ios`) is connected.
4
+ *
5
+ * Method: clawly.isOnline({ host? }) → { isOnline: boolean }
6
+ */
7
+
8
+ import {$} from 'zx'
9
+
10
+ import type {PluginApi} from './index'
11
+
12
+ $.verbose = false
13
+
14
+ const DEFAULT_HOST = 'openclaw-ios'
15
+
16
+ interface PresenceEntry {
17
+ host?: string
18
+ reason?: string
19
+ }
20
+
21
+ /**
22
+ * Shells out to `openclaw gateway call system-presence` and checks
23
+ * whether the given host has a non-"disconnect" entry.
24
+ */
25
+ export async function isClientOnline(host = DEFAULT_HOST): Promise<boolean> {
26
+ try {
27
+ const result = await $`openclaw gateway call system-presence --json`
28
+ const output = result.stdout.trim()
29
+ let jsonStr = output
30
+ if (!output.startsWith('[')) {
31
+ jsonStr = output.slice(output.indexOf('\n['))
32
+ }
33
+ console.log({jsonStr})
34
+ const entries: PresenceEntry[] = JSON.parse(jsonStr)
35
+ const entry = entries.find((e) => e.host === host)
36
+ if (!entry) return false
37
+ return entry.reason === 'connect'
38
+ } catch {
39
+ return false
40
+ }
41
+ }
42
+
43
+ export function registerPresence(api: PluginApi) {
44
+ api.registerGatewayMethod('clawly.isOnline', async ({params, respond}) => {
45
+ const host = typeof params.host === 'string' ? params.host : DEFAULT_HOST
46
+ const isOnline = await isClientOnline(host)
47
+ respond(true, {isOnline})
48
+ })
49
+
50
+ api.logger.info('presence: registered clawly.isOnline method')
51
+ }