@2en/clawly-plugins 1.30.0-beta.4 → 1.30.0-beta.5

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.
@@ -263,6 +263,22 @@ describe('cron-delivery', () => {
263
263
  })
264
264
  })
265
265
 
266
+ test('skips HEARTBEAT OK with space (bare)', async () => {
267
+ const {api, logs, handlers} = createMockApi()
268
+ registerCronDelivery(api)
269
+
270
+ await handlers.get('agent_end')!(
271
+ {messages: makeMessages('HEARTBEAT OK')},
272
+ {sessionKey: 'agent:clawly:cron:weather-check', agentId: 'clawly'},
273
+ )
274
+
275
+ expect(injectCalls).toHaveLength(0)
276
+ expect(logs).toContainEqual({
277
+ level: 'info',
278
+ msg: expect.stringContaining('skipped (filtered: heartbeat ack)'),
279
+ })
280
+ })
281
+
266
282
  test('skips empty text', async () => {
267
283
  const {api, logs, handlers} = createMockApi()
268
284
  registerCronDelivery(api)
@@ -446,6 +446,22 @@ describe('shouldSkipPushForMessage', () => {
446
446
  expect(shouldSkipPushForMessage('The token HEARTBEAT_OK is used for health checks.')).toBeNull()
447
447
  })
448
448
 
449
+ test('skips heartbeat ack with space variant (HEARTBEAT OK)', () => {
450
+ expect(shouldSkipPushForMessage('HEARTBEAT OK')).toBe('heartbeat ack')
451
+ expect(shouldSkipPushForMessage('HEARTBEAT OK.')).toBe('heartbeat ack')
452
+ expect(shouldSkipPushForMessage('HEARTBEAT OK\n')).toBe('heartbeat ack')
453
+ })
454
+
455
+ test('skips short content ending with HEARTBEAT OK (space variant)', () => {
456
+ expect(shouldSkipPushForMessage('All good. HEARTBEAT OK')).toBe('heartbeat ack')
457
+ expect(shouldSkipPushForMessage('Nothing to report. HEARTBEAT OK。')).toBe('heartbeat ack')
458
+ })
459
+
460
+ test('skips HEARTBEAT OK at start with short status note (space variant)', () => {
461
+ expect(shouldSkipPushForMessage('HEARTBEAT OK — all systems nominal.')).toBe('heartbeat ack')
462
+ expect(shouldSkipPushForMessage('HEARTBEAT OK. Nothing to report.')).toBe('heartbeat ack')
463
+ })
464
+
449
465
  test('skips system prompt leak', () => {
450
466
  expect(
451
467
  shouldSkipPushForMessage('Here is some Conversation info (untrusted metadata) text'),
@@ -584,6 +600,33 @@ describe('offline-push with filtered messages', () => {
584
600
  expect(lastPushOpts?.body?.endsWith('…')).toBe(true)
585
601
  })
586
602
 
603
+ test('sends push for long content and strips HEARTBEAT OK (space variant) from body', async () => {
604
+ const {api, logs, handlers} = createMockApi()
605
+ registerOfflinePush(api)
606
+
607
+ const longContent = 'A'.repeat(301)
608
+ await handlers.get('agent_end')!(
609
+ {
610
+ messages: [
611
+ {
612
+ role: 'assistant',
613
+ content: `${longContent}\n\nHEARTBEAT OK`,
614
+ },
615
+ ],
616
+ },
617
+ {sessionKey: 'agent:clawly:main'},
618
+ )
619
+
620
+ expect(logs).toContainEqual({
621
+ level: 'info',
622
+ msg: expect.stringContaining('notified (session=agent:clawly:main)'),
623
+ })
624
+ // HEARTBEAT OK should be stripped from body, content truncated to 140
625
+ expect(lastPushOpts?.body?.length).toBe(141)
626
+ expect(lastPushOpts?.body?.endsWith('…')).toBe(true)
627
+ expect(lastPushOpts?.body).not.toContain('HEARTBEAT')
628
+ })
629
+
587
630
  test('sends push for normal message text', async () => {
588
631
  const {api, logs, handlers} = createMockApi()
589
632
  registerOfflinePush(api)
@@ -160,12 +160,12 @@ export function shouldSkipPushForMessage(text: string): string | null {
160
160
  // Heartbeat acknowledgment — strip HEARTBEAT_OK from both edges (mirrors
161
161
  // OpenClaw's stripTokenAtEdges). Skip if remaining text ≤ ackMaxChars (300).
162
162
  const HEARTBEAT_ACK_MAX_CHARS = 300
163
- const hasAtEnd = /HEARTBEAT_OK[\p{P}\s]*$/u.test(text)
164
- const hasAtStart = /^[\p{P}\s]*HEARTBEAT_OK/u.test(text)
163
+ const hasAtEnd = /HEARTBEAT[_ ]OK[\p{P}\s]*$/u.test(text)
164
+ const hasAtStart = /^[\p{P}\s]*HEARTBEAT[_ ]OK/u.test(text)
165
165
  if (hasAtEnd || hasAtStart) {
166
166
  let stripped = text
167
- if (hasAtEnd) stripped = stripped.replace(/HEARTBEAT_OK[\p{P}\s]*$/u, '')
168
- if (hasAtStart) stripped = stripped.replace(/^[\p{P}\s]*HEARTBEAT_OK[\p{P}\s]*/u, '')
167
+ if (hasAtEnd) stripped = stripped.replace(/HEARTBEAT[_ ]OK[\p{P}\s]*$/u, '')
168
+ if (hasAtStart) stripped = stripped.replace(/^[\p{P}\s]*HEARTBEAT[_ ]OK[\p{P}\s]*/u, '')
169
169
  stripped = stripped.trim()
170
170
  if (stripped.length <= HEARTBEAT_ACK_MAX_CHARS) return 'heartbeat ack'
171
171
  }
@@ -308,12 +308,12 @@ export function registerOfflinePush(api: PluginApi) {
308
308
  const noHeartbeat =
309
309
  (() => {
310
310
  if (!fullText) return null
311
- const atEnd = /HEARTBEAT_OK[\p{P}\s]*$/u.test(fullText)
312
- const atStart = /^[\p{P}\s]*HEARTBEAT_OK/u.test(fullText)
311
+ const atEnd = /HEARTBEAT[_ ]OK[\p{P}\s]*$/u.test(fullText)
312
+ const atStart = /^[\p{P}\s]*HEARTBEAT[_ ]OK/u.test(fullText)
313
313
  if (!atEnd && !atStart) return fullText.trim()
314
314
  let s = fullText
315
- if (atEnd) s = s.replace(/HEARTBEAT_OK[\p{P}\s]*$/u, '')
316
- if (atStart) s = s.replace(/^[\p{P}\s]*HEARTBEAT_OK[\p{P}\s]*/u, '')
315
+ if (atEnd) s = s.replace(/HEARTBEAT[_ ]OK[\p{P}\s]*$/u, '')
316
+ if (atStart) s = s.replace(/^[\p{P}\s]*HEARTBEAT[_ ]OK[\p{P}\s]*/u, '')
317
317
  return s.trim()
318
318
  })() ?? null
319
319
  const cleaned = noHeartbeat ? stripPlaceholders(noHeartbeat) : null
@@ -27,14 +27,17 @@ export function isOnlineEntry(entry: PresenceEntry | undefined): boolean {
27
27
  }
28
28
 
29
29
  /**
30
- * Shells out to `openclaw gateway call system-presence` and checks
31
- * whether ANY device has a foreground presence entry.
30
+ * Shells out to `openclaw gateway call system-presence` and returns true
31
+ * if any device has a `foreground` presence entry.
32
+ *
33
+ * False-negatives (beacon lapsed) are handled client-side — the mobile
34
+ * app suppresses `agent_end` notifications while in the foreground.
32
35
  */
33
36
  export async function isClientOnline(): Promise<boolean> {
34
37
  try {
35
38
  const result = await $`openclaw gateway call system-presence --json`
36
- const jsonStr = stripCliLogs(result.stdout)
37
- const entries: PresenceEntry[] = JSON.parse(jsonStr)
39
+ const parsed: unknown = JSON.parse(stripCliLogs(result.stdout))
40
+ const entries: PresenceEntry[] = Array.isArray(parsed) ? parsed : []
38
41
  return entries.some(isOnlineEntry)
39
42
  } catch {
40
43
  return false
package/gateway-fetch.ts CHANGED
@@ -11,11 +11,18 @@ export type HandlerResult = {ok: boolean; data?: unknown; error?: {code: string;
11
11
  export function getGatewayConfig(api: PluginApi): GatewayCfg {
12
12
  const cfg = api.pluginConfig && typeof api.pluginConfig === 'object' ? api.pluginConfig : {}
13
13
  const c = cfg as Record<string, unknown>
14
- const baseUrl =
15
- typeof c.skillGatewayBaseUrl === 'string'
16
- ? (c.skillGatewayBaseUrl as string).replace(/\/$/, '')
17
- : ''
18
- const token = typeof c.skillGatewayToken === 'string' ? (c.skillGatewayToken as string) : ''
14
+
15
+ // URL: prefer clawlyApiBaseUrl, fall back to deprecated skillGatewayBaseUrl
16
+ const rawUrl =
17
+ (typeof c.clawlyApiBaseUrl === 'string' ? (c.clawlyApiBaseUrl as string) : '') ||
18
+ (typeof c.skillGatewayBaseUrl === 'string' ? (c.skillGatewayBaseUrl as string) : '')
19
+ const baseUrl = rawUrl.replace(/\/$/, '')
20
+
21
+ // Token: prefer modelGatewayToken, fall back to deprecated skillGatewayToken
22
+ const token =
23
+ (typeof c.modelGatewayToken === 'string' ? (c.modelGatewayToken as string) : '') ||
24
+ (typeof c.skillGatewayToken === 'string' ? (c.skillGatewayToken as string) : '')
25
+
19
26
  return {baseUrl, token}
20
27
  }
21
28
 
package/index.ts CHANGED
@@ -80,7 +80,7 @@ export default {
80
80
  registerAutoPair(api)
81
81
  registerAutoUpdate(api)
82
82
 
83
- // Email & calendar (optional — requires skillGatewayBaseUrl + skillGatewayToken in config)
83
+ // Email & calendar (optional — requires API base URL + token)
84
84
  const gw = getGatewayConfig(api)
85
85
  if (gw.baseUrl && gw.token) {
86
86
  registerEmail(api, gw)
@@ -23,6 +23,9 @@ export interface NativeCalendar {
23
23
  type: string
24
24
  }
25
25
 
26
+ export type AlarmMethod = 'alarm' | 'alert' | 'email' | 'default' | 'sms'
27
+ export type AlarmInput = {relativeOffset?: number; method?: AlarmMethod}
28
+
26
29
  export interface NativeCalendarEvent {
27
30
  id: string
28
31
  calendarId: string
@@ -35,6 +38,7 @@ export interface NativeCalendarEvent {
35
38
  url: string
36
39
  timeZone: string
37
40
  organizer: string
41
+ alarms?: AlarmInput[]
38
42
  }
39
43
 
40
44
  export interface CalendarCache {
@@ -47,6 +47,7 @@
47
47
  "defaultNoInput": { "type": "boolean" },
48
48
  "defaultTimeoutMs": { "type": "number", "minimum": 1000 },
49
49
  "configPath": { "type": "string" },
50
+ "clawlyApiBaseUrl": { "type": "string" },
50
51
  "skillGatewayBaseUrl": { "type": "string" },
51
52
  "skillGatewayToken": { "type": "string" },
52
53
  "modelGatewayBaseUrl": { "type": "string" },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@2en/clawly-plugins",
3
- "version": "1.30.0-beta.4",
3
+ "version": "1.30.0-beta.5",
4
4
  "module": "index.ts",
5
5
  "type": "module",
6
6
  "repository": {
@@ -158,6 +158,26 @@ export function registerCalendarTools(api: PluginApi) {
158
158
  location: {type: 'string', description: 'Event location'},
159
159
  notes: {type: 'string', description: 'Event description/notes'},
160
160
  timeZone: {type: 'string', description: 'IANA time zone (e.g. America/New_York)'},
161
+ alarms: {
162
+ type: 'array',
163
+ description:
164
+ 'Array of alarm objects to set reminders. Each alarm has a relativeOffset in minutes (negative = before event). Example: [{relativeOffset: -15}] for 15 minutes before. Only alarms with valid relativeOffset (number) or method (string) will be applied. To verify reminders were set, use clawly_calendar_list_events after creation.',
165
+ items: {
166
+ type: 'object',
167
+ properties: {
168
+ relativeOffset: {
169
+ type: 'number',
170
+ description:
171
+ 'Minutes relative to the event start time. Use negative values for reminders before the event (e.g. -15 = 15 min before, -60 = 1 hour before).',
172
+ },
173
+ method: {
174
+ type: 'string',
175
+ enum: ['alarm', 'alert', 'email', 'default', 'sms'],
176
+ description: 'Alarm method (Android only). iOS always uses notification.',
177
+ },
178
+ },
179
+ },
180
+ },
161
181
  },
162
182
  },
163
183
  async execute(_toolCallId, params) {
@@ -211,6 +231,9 @@ export function registerCalendarTools(api: PluginApi) {
211
231
  ...(params.location ? {location: params.location} : {}),
212
232
  ...(params.notes ? {notes: params.notes} : {}),
213
233
  ...(params.timeZone ? {timeZone: params.timeZone} : {}),
234
+ ...(Array.isArray(params.alarms) && params.alarms.length > 0
235
+ ? {alarms: params.alarms}
236
+ : {}),
214
237
  }
215
238
 
216
239
  const resultPromise = waitForActionResult(actionId)
@@ -290,6 +313,26 @@ export function registerCalendarTools(api: PluginApi) {
290
313
  location: {type: 'string', description: 'New event location'},
291
314
  notes: {type: 'string', description: 'New event description/notes'},
292
315
  timeZone: {type: 'string', description: 'IANA time zone (e.g. America/New_York)'},
316
+ alarms: {
317
+ type: 'array',
318
+ description:
319
+ 'Array of alarm objects to set reminders. Each alarm has a relativeOffset in minutes (negative = before event). Example: [{relativeOffset: -15}] for 15 minutes before. Replaces all existing alarms. Pass an empty array [] to clear all existing reminders.',
320
+ items: {
321
+ type: 'object',
322
+ properties: {
323
+ relativeOffset: {
324
+ type: 'number',
325
+ description:
326
+ 'Minutes relative to the event start time. Use negative values for reminders before the event (e.g. -15 = 15 min before, -60 = 1 hour before).',
327
+ },
328
+ method: {
329
+ type: 'string',
330
+ enum: ['alarm', 'alert', 'email', 'default', 'sms'],
331
+ description: 'Alarm method (Android only). iOS always uses notification.',
332
+ },
333
+ },
334
+ },
335
+ },
293
336
  },
294
337
  },
295
338
  async execute(_toolCallId, params) {
@@ -314,6 +357,7 @@ export function registerCalendarTools(api: PluginApi) {
314
357
  ...(params.location !== undefined ? {location: params.location} : {}),
315
358
  ...(params.notes !== undefined ? {notes: params.notes} : {}),
316
359
  ...(params.timeZone !== undefined ? {timeZone: params.timeZone} : {}),
360
+ ...(Array.isArray(params.alarms) ? {alarms: params.alarms} : {}),
317
361
  }
318
362
 
319
363
  const resultPromise = waitForActionResult(actionId)