@2en/clawly-plugins 1.23.0 → 1.24.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.
@@ -274,15 +274,21 @@ describe('shouldSkipPushForMessage', () => {
274
274
  expect(shouldSkipPushForMessage(' NO_REPLY ')).toBe('silent reply')
275
275
  })
276
276
 
277
- test('skips short heartbeat ack', () => {
277
+ test('skips heartbeat ack when HEARTBEAT_OK is the only content', () => {
278
278
  expect(shouldSkipPushForMessage('HEARTBEAT_OK')).toBe('heartbeat ack')
279
- expect(shouldSkipPushForMessage('All good. HEARTBEAT_OK')).toBe('heartbeat ack')
279
+ expect(shouldSkipPushForMessage('HEARTBEAT_OK.')).toBe('heartbeat ack')
280
+ expect(shouldSkipPushForMessage('HEARTBEAT_OK\n')).toBe('heartbeat ack')
280
281
  })
281
282
 
282
- test('skips verbose heartbeat ack ending with HEARTBEAT_OK', () => {
283
+ test('does NOT skip meaningful content ending with HEARTBEAT_OK', () => {
284
+ expect(shouldSkipPushForMessage('All good. HEARTBEAT_OK')).toBeNull()
285
+ expect(shouldSkipPushForMessage('Nothing to report. HEARTBEAT_OK。')).toBeNull()
286
+ })
287
+
288
+ test('does NOT skip verbose heartbeat response with content before HEARTBEAT_OK', () => {
283
289
  const verbose =
284
290
  'The user said hello recently. Looking at HEARTBEAT.md checklist: nothing needs attention. HEARTBEAT_OK'
285
- expect(shouldSkipPushForMessage(verbose)).toBe('heartbeat ack')
291
+ expect(shouldSkipPushForMessage(verbose)).toBeNull()
286
292
  })
287
293
 
288
294
  test('does not skip message mentioning HEARTBEAT_OK mid-text', () => {
@@ -291,10 +297,6 @@ describe('shouldSkipPushForMessage', () => {
291
297
  ).toBeNull()
292
298
  })
293
299
 
294
- test('skips heartbeat ack with non-ASCII trailing punctuation', () => {
295
- expect(shouldSkipPushForMessage('Nothing to report. HEARTBEAT_OK。')).toBe('heartbeat ack')
296
- })
297
-
298
300
  test('skips system prompt leak', () => {
299
301
  expect(
300
302
  shouldSkipPushForMessage('Here is some Conversation info (untrusted metadata) text'),
@@ -356,6 +358,29 @@ describe('offline-push with filtered messages', () => {
356
358
  })
357
359
  })
358
360
 
361
+ test('sends push for meaningful heartbeat response and strips HEARTBEAT_OK from body', async () => {
362
+ const {api, logs, handlers} = createMockApi()
363
+ registerOfflinePush(api)
364
+
365
+ await handlers.get('agent_end')!(
366
+ {
367
+ messages: [
368
+ {
369
+ role: 'assistant',
370
+ content: 'Hey! Just checking in — saw some interesting news today.\n\nHEARTBEAT_OK',
371
+ },
372
+ ],
373
+ },
374
+ {sessionKey: 'agent:clawly:main'},
375
+ )
376
+
377
+ expect(logs).toContainEqual({
378
+ level: 'info',
379
+ msg: expect.stringContaining('notified (session=agent:clawly:main)'),
380
+ })
381
+ expect(lastPushOpts?.body).toBe('Hey! Just checking in — saw some interesting news today.')
382
+ })
383
+
359
384
  test('sends push for normal message text', async () => {
360
385
  const {api, logs, handlers} = createMockApi()
361
386
  registerOfflinePush(api)
@@ -79,8 +79,11 @@ export function shouldSkipPushForMessage(text: string): string | null {
79
79
  // Agent sentinel "nothing to say" — mobile hides as "silentReply"
80
80
  if (trimmed === 'NO_REPLY') return 'silent reply'
81
81
 
82
- // Heartbeat acknowledgment (HEARTBEAT_OK as ending sentinel) — mobile hides as "heartbeatAck"
83
- if (/HEARTBEAT_OK[\p{P}\s]*$/u.test(text)) return 'heartbeat ack'
82
+ // Heartbeat acknowledgment (HEARTBEAT_OK as ending sentinel) — only skip if no substantial content before it
83
+ if (/HEARTBEAT_OK[\p{P}\s]*$/u.test(text)) {
84
+ const stripped = text.replace(/HEARTBEAT_OK[\p{P}\s]*$/u, '').trim()
85
+ if (stripped.length === 0) return 'heartbeat ack'
86
+ }
84
87
 
85
88
  // Agent echoed system prompt metadata — mobile hides as "systemPromptLeak"
86
89
  if (text.includes('Conversation info (untrusted metadata)')) return 'system prompt leak'
@@ -118,8 +121,9 @@ export function registerOfflinePush(api: PluginApi) {
118
121
  return
119
122
  }
120
123
 
121
- const preview = fullText && fullText.length > 140 ? `${fullText.slice(0, 140)}…` : fullText
122
- const body = preview ?? 'Your response is ready'
124
+ const cleaned = fullText?.replace(/HEARTBEAT_OK[\p{P}\s]*$/u, '').trim() ?? null
125
+ const preview = cleaned && cleaned.length > 140 ? `${cleaned.slice(0, 140)}…` : cleaned
126
+ const body = preview || 'Your response is ready'
123
127
 
124
128
  const sent = await sendPushNotification(
125
129
  {
package/index.ts CHANGED
@@ -27,9 +27,11 @@
27
27
  * - before_tool_call — enforces delivery fields on cron.create
28
28
  * - agent_end — sends push notification when client is offline
29
29
  * - gateway_start — auto-approves device pairing for Clawly mobile clients (clientId: openclaw-ios)
30
+ * - gateway_start — registers auto-update cron job (0 3 * * *) for clawly-plugins
30
31
  */
31
32
 
32
33
  import {registerAutoPair} from './auto-pair'
34
+ import {registerAutoUpdate} from './internal/hooks/auto-update'
33
35
  import {registerCalendar} from './calendar'
34
36
  import {registerCommands} from './command'
35
37
  import {setupConfig} from './config-setup'
@@ -186,6 +188,7 @@ export default {
186
188
  setupConfig(api)
187
189
  registerGateway(api)
188
190
  registerAutoPair(api)
191
+ registerAutoUpdate(api)
189
192
 
190
193
  // Email & calendar (optional — requires skillGatewayBaseUrl + skillGatewayToken in config)
191
194
  const gw = getGatewayConfig(api)
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Registers a cron job (0 3 * * *) on gateway_start that
3
+ * checks user presence and auto-updates clawly-plugins when offline.
4
+ */
5
+ import {$} from 'zx'
6
+
7
+ import type {PluginApi} from '../../index'
8
+
9
+ $.verbose = false
10
+
11
+ // ── Types ──────────────────────────────────────────────────────
12
+
13
+ interface CronJobEntry {
14
+ id: string
15
+ name: string
16
+ description: string
17
+ enabled: boolean
18
+ schedule: {kind: 'cron'; expr: string; tz?: string} | {kind: string}
19
+ sessionTarget: string
20
+ wakeMode: string
21
+ payload: {kind: string; message?: string}
22
+ delivery: {mode: string}
23
+ }
24
+
25
+ interface JobConfig {
26
+ description: string
27
+ cron: string
28
+ session: 'main' | 'isolated'
29
+ wake: 'now' | 'next-heartbeat'
30
+ deliver: boolean
31
+ message: string
32
+ }
33
+
34
+ // ── Config ─────────────────────────────────────────────────────
35
+
36
+ const JOB_NAME = 'Clawly Plugins Auto-Update'
37
+
38
+ const JOB_CONFIG: JobConfig = {
39
+ description: 'Auto-update clawly-plugins when user is offline',
40
+ cron: '0 3 * * *',
41
+ session: 'isolated',
42
+ wake: 'now',
43
+ deliver: false,
44
+ message: [
45
+ 'Auto-update check for clawly-plugins.',
46
+ '',
47
+ 'Steps:',
48
+ '1. Call the clawly_is_user_online tool to check if the user is online.',
49
+ '2. If the user IS online, stop — do nothing.',
50
+ '3. If the user is NOT online, run this exact command in terminal:',
51
+ ` openclaw gateway call clawly.plugins.update --json --params '${JSON.stringify({pluginId: 'clawly-plugins', npmPkgName: '@2en/clawly-plugins', strategy: 'force', restart: true})}'`,
52
+ '',
53
+ 'Do not send any messages to the user. Just execute the steps silently.',
54
+ ].join('\n'),
55
+ }
56
+
57
+ // ── Helpers ────────────────────────────────────────────────────
58
+
59
+ function configToArgs(config: JobConfig): string[] {
60
+ const args = [
61
+ '--description',
62
+ config.description,
63
+ '--cron',
64
+ config.cron,
65
+ '--session',
66
+ config.session,
67
+ '--wake',
68
+ config.wake,
69
+ '--message',
70
+ config.message,
71
+ ]
72
+ if (!config.deliver) args.push('--no-deliver')
73
+ return args
74
+ }
75
+
76
+ function needsUpdate(job: CronJobEntry, config: JobConfig): boolean {
77
+ if (job.description !== config.description) return true
78
+ if (job.schedule.kind !== 'cron' || (job.schedule as {expr: string}).expr !== config.cron)
79
+ return true
80
+ if (job.sessionTarget !== config.session) return true
81
+ if (job.wakeMode !== config.wake) return true
82
+ if (job.payload.message !== config.message) return true
83
+ const wantMode = config.deliver ? 'announce' : 'none'
84
+ if (job.delivery.mode !== wantMode) return true
85
+ return false
86
+ }
87
+
88
+ async function findJob(): Promise<CronJobEntry | null> {
89
+ try {
90
+ const {stdout} = await $`openclaw cron list --json`
91
+ const parsed = JSON.parse(stdout)
92
+ const jobs: unknown[] = parsed?.jobs ?? parsed ?? []
93
+ if (!Array.isArray(jobs)) return null
94
+ return (jobs.find((j: any) => j.name === JOB_NAME) as CronJobEntry) ?? null
95
+ } catch {
96
+ return null
97
+ }
98
+ }
99
+
100
+ // ── Registration ───────────────────────────────────────────────
101
+
102
+ export function registerAutoUpdate(api: PluginApi) {
103
+ api.on('gateway_start', async () => {
104
+ try {
105
+ const existing = await findJob()
106
+
107
+ if (existing) {
108
+ if (!needsUpdate(existing, JOB_CONFIG)) {
109
+ api.logger.info('auto-update: cron job up to date')
110
+ return
111
+ }
112
+ await $`openclaw cron edit ${[existing.id, ...configToArgs(JOB_CONFIG)]}`
113
+ api.logger.info('auto-update: updated cron job')
114
+ } else {
115
+ await $`openclaw cron add ${['--name', JOB_NAME, ...configToArgs(JOB_CONFIG)]}`
116
+ api.logger.info('auto-update: registered cron job')
117
+ }
118
+ } catch (err) {
119
+ api.logger.warn(`auto-update: failed to register cron job: ${String(err)}`)
120
+ }
121
+ })
122
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@2en/clawly-plugins",
3
- "version": "1.23.0",
3
+ "version": "1.24.0",
4
4
  "module": "index.ts",
5
5
  "type": "module",
6
6
  "repository": {
@@ -26,7 +26,8 @@
26
26
  "outbound.ts",
27
27
  "model-gateway-setup.ts",
28
28
  "skill-command-restore.ts",
29
- "openclaw.plugin.json"
29
+ "openclaw.plugin.json",
30
+ "internal"
30
31
  ],
31
32
  "publishConfig": {
32
33
  "access": "public"