@2en/clawly-plugins 1.30.0-beta.0 → 1.30.0-beta.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/config-setup.ts CHANGED
@@ -490,7 +490,7 @@ export function patchContextPruning(config: Record<string, unknown>): boolean {
490
490
  return false
491
491
  }
492
492
 
493
- const DEFAULT_HEARTBEAT_MODEL = `${PROVIDER_NAME}/qwen/qwen3.5-flash-02-23`
493
+ const DEFAULT_HEARTBEAT_MODEL = `${PROVIDER_NAME}/moonshotai/kimi-k2.5`
494
494
 
495
495
  function resolveDefaultHeartbeatModel(pc: ConfigPluginConfig): string {
496
496
  return toProviderModelId(pc.defaultHeartbeatModel) || DEFAULT_HEARTBEAT_MODEL
@@ -279,17 +279,35 @@ describe('cron-delivery', () => {
279
279
  })
280
280
  })
281
281
 
282
- test('does not skip meaningful content ending with HEARTBEAT_OK', async () => {
282
+ test('does not skip meaningful content ending with HEARTBEAT_OK when long enough', async () => {
283
283
  const {api, handlers} = createMockApi()
284
284
  registerCronDelivery(api)
285
285
 
286
+ // Content after stripping HEARTBEAT_OK must exceed 300 chars to pass through
287
+ const longContent = 'A'.repeat(301) + ' HEARTBEAT_OK'
286
288
  await handlers.get('agent_end')!(
287
- {messages: makeMessages('Weather is good today. HEARTBEAT_OK')},
289
+ {messages: makeMessages(longContent)},
288
290
  {sessionKey: 'agent:clawly:cron:weather-check', agentId: 'clawly'},
289
291
  )
290
292
 
291
293
  expect(injectCalls).toHaveLength(1)
292
294
  })
295
+
296
+ test('skips short content ending with HEARTBEAT_OK', async () => {
297
+ const {api, logs, handlers} = createMockApi()
298
+ registerCronDelivery(api)
299
+
300
+ await handlers.get('agent_end')!(
301
+ {messages: makeMessages('Weather is good today. HEARTBEAT_OK')},
302
+ {sessionKey: 'agent:clawly:cron:weather-check', agentId: 'clawly'},
303
+ )
304
+
305
+ expect(injectCalls).toHaveLength(0)
306
+ expect(logs).toContainEqual({
307
+ level: 'info',
308
+ msg: expect.stringContaining('skipped (filtered: heartbeat ack)'),
309
+ })
310
+ })
293
311
  })
294
312
 
295
313
  describe('agentId validation', () => {
@@ -327,7 +345,9 @@ describe('cron-delivery', () => {
327
345
  ])
328
346
  expect(logs).toContainEqual({
329
347
  level: 'info',
330
- msg: 'cron-delivery: injected into agent:clawly:main (messageId=msg-001)',
348
+ msg: expect.stringContaining(
349
+ 'cron-delivery: injected into agent:clawly:main (messageId=msg-001)',
350
+ ),
331
351
  })
332
352
  })
333
353
 
@@ -360,7 +380,7 @@ describe('cron-delivery', () => {
360
380
  expect(injectCalls).toHaveLength(0)
361
381
  expect(logs).toContainEqual({
362
382
  level: 'error',
363
- msg: 'cron-delivery: sessions.resolve failed: JSON parse error',
383
+ msg: expect.stringContaining('sessions.resolve failed: JSON parse error'),
364
384
  })
365
385
  })
366
386
 
@@ -378,7 +398,7 @@ describe('cron-delivery', () => {
378
398
  expect(injectCalls).toHaveLength(1)
379
399
  expect(logs).toContainEqual({
380
400
  level: 'error',
381
- msg: 'cron-delivery: chat.inject failed: timeout',
401
+ msg: expect.stringContaining('chat.inject failed: timeout'),
382
402
  })
383
403
  })
384
404
  })
@@ -59,9 +59,13 @@ export function registerCronDelivery(api: PluginApi) {
59
59
  `cron-delivery[debug]: agent_end fired sessionKey=${sessionKey} agentId=${agentId ?? 'undefined'} trigger=${String(ctx?.trigger ?? 'undefined')}`,
60
60
  )
61
61
 
62
+ const t0 = Date.now()
62
63
  try {
63
64
  // Extract raw assistant text (preserving formatting)
64
65
  const text = getRawLastAssistantText(event.messages)
66
+ api.logger.info(
67
+ `cron-delivery[debug]: extracted text len=${text?.length ?? 'null'} elapsed=${Date.now() - t0}ms`,
68
+ )
65
69
  if (text == null) {
66
70
  api.logger.info('cron-delivery: skipped (no assistant message)')
67
71
  if (sessionKey) markCronDeliverySkipped(sessionKey, 'no assistant message')
@@ -97,9 +101,18 @@ export function registerCronDelivery(api: PluginApi) {
97
101
  return
98
102
  }
99
103
 
104
+ api.logger.info(
105
+ `cron-delivery[debug]: resolving session for agentId=${agentId} elapsed=${Date.now() - t0}ms`,
106
+ )
100
107
  const mainSessionKey = await resolveSessionKey(agentId, api)
108
+ api.logger.info(
109
+ `cron-delivery[debug]: resolved mainSessionKey=${mainSessionKey} elapsed=${Date.now() - t0}ms`,
110
+ )
101
111
 
102
112
  // Inject the cron result into the main session
113
+ api.logger.info(
114
+ `cron-delivery[debug]: injecting message len=${text.length} into ${mainSessionKey} elapsed=${Date.now() - t0}ms`,
115
+ )
103
116
  const result = await injectAssistantMessage(
104
117
  {
105
118
  sessionKey: mainSessionKey,
@@ -110,12 +123,12 @@ export function registerCronDelivery(api: PluginApi) {
110
123
 
111
124
  if (sessionKey) markCronDelivered(sessionKey)
112
125
  api.logger.info(
113
- `cron-delivery: injected into ${mainSessionKey} (messageId=${result.messageId})`,
126
+ `cron-delivery: injected into ${mainSessionKey} (messageId=${result.messageId}) elapsed=${Date.now() - t0}ms`,
114
127
  )
115
128
  } catch (err) {
116
129
  const msg = err instanceof Error ? err.message : String(err)
117
130
  if (sessionKey) markCronDeliverySkipped(sessionKey, msg)
118
- api.logger.error(`cron-delivery: ${msg}`)
131
+ api.logger.error(`cron-delivery: error after ${Date.now() - t0}ms — ${msg}`)
119
132
  }
120
133
  })
121
134
 
@@ -391,21 +391,40 @@ describe('shouldSkipPushForMessage', () => {
391
391
  expect(shouldSkipPushForMessage('HEARTBEAT_OK\n')).toBe('heartbeat ack')
392
392
  })
393
393
 
394
- test('does NOT skip meaningful content ending with HEARTBEAT_OK', () => {
395
- expect(shouldSkipPushForMessage('All good. HEARTBEAT_OK')).toBeNull()
396
- expect(shouldSkipPushForMessage('Nothing to report. HEARTBEAT_OK')).toBeNull()
394
+ test('skips short content ending with HEARTBEAT_OK (under ackMaxChars threshold)', () => {
395
+ // "All good." is 9 chars — well under 300 threshold, matches OpenClaw heartbeat runner behavior
396
+ expect(shouldSkipPushForMessage('All good. HEARTBEAT_OK')).toBe('heartbeat ack')
397
+ expect(shouldSkipPushForMessage('Nothing to report. HEARTBEAT_OK。')).toBe('heartbeat ack')
397
398
  })
398
399
 
399
- test('does NOT skip verbose heartbeat response with content before HEARTBEAT_OK', () => {
400
+ test('skips verbose heartbeat response under ackMaxChars threshold', () => {
401
+ // 89 chars remaining — under 300, matches OpenClaw behavior
400
402
  const verbose =
401
403
  'The user said hello recently. Looking at HEARTBEAT.md checklist: nothing needs attention. HEARTBEAT_OK'
402
- expect(shouldSkipPushForMessage(verbose)).toBeNull()
404
+ expect(shouldSkipPushForMessage(verbose)).toBe('heartbeat ack')
403
405
  })
404
406
 
405
- test('does not skip message mentioning HEARTBEAT_OK mid-text', () => {
407
+ test('skips HEARTBEAT_OK at start with short status note', () => {
408
+ expect(shouldSkipPushForMessage('HEARTBEAT_OK — all systems nominal.')).toBe('heartbeat ack')
409
+ expect(shouldSkipPushForMessage('HEARTBEAT_OK. Nothing to report.')).toBe('heartbeat ack')
410
+ })
411
+
412
+ test('does NOT skip HEARTBEAT_OK with >300 chars of real content', () => {
413
+ const longContent = 'A'.repeat(301)
414
+ expect(shouldSkipPushForMessage(`HEARTBEAT_OK ${longContent}`)).toBeNull()
415
+ expect(shouldSkipPushForMessage(`${longContent} HEARTBEAT_OK`)).toBeNull()
416
+ })
417
+
418
+ test('skips message mentioning HEARTBEAT_OK at start with short remaining text', () => {
419
+ // "is a status code I output after each check." is 45 chars — under 300, matches OpenClaw's stripTokenAtEdges
406
420
  expect(
407
421
  shouldSkipPushForMessage('HEARTBEAT_OK is a status code I output after each check.'),
408
- ).toBeNull()
422
+ ).toBe('heartbeat ack')
423
+ })
424
+
425
+ test('does NOT skip HEARTBEAT_OK appearing only in middle of text', () => {
426
+ expect(shouldSkipPushForMessage('You asked about HEARTBEAT_OK earlier.')).toBeNull()
427
+ expect(shouldSkipPushForMessage('The token HEARTBEAT_OK is used for health checks.')).toBeNull()
409
428
  })
410
429
 
411
430
  test('skips system prompt leak', () => {
@@ -497,7 +516,7 @@ describe('offline-push with filtered messages', () => {
497
516
  })
498
517
  })
499
518
 
500
- test('sends push for meaningful heartbeat response and strips HEARTBEAT_OK from body', async () => {
519
+ test('skips push for short heartbeat response with HEARTBEAT_OK (under ackMaxChars)', async () => {
501
520
  const {api, logs, handlers} = createMockApi()
502
521
  registerOfflinePush(api)
503
522
 
@@ -513,11 +532,37 @@ describe('offline-push with filtered messages', () => {
513
532
  {sessionKey: 'agent:clawly:main'},
514
533
  )
515
534
 
535
+ expect(logs).toContainEqual({
536
+ level: 'info',
537
+ msg: expect.stringContaining('skipped (filtered: heartbeat ack)'),
538
+ })
539
+ expect(logs.filter((l) => l.msg.includes('notified'))).toHaveLength(0)
540
+ })
541
+
542
+ test('sends push for long heartbeat response over ackMaxChars and strips HEARTBEAT_OK from body', async () => {
543
+ const {api, logs, handlers} = createMockApi()
544
+ registerOfflinePush(api)
545
+
546
+ const longContent = 'A'.repeat(301)
547
+ await handlers.get('agent_end')!(
548
+ {
549
+ messages: [
550
+ {
551
+ role: 'assistant',
552
+ content: `${longContent}\n\nHEARTBEAT_OK`,
553
+ },
554
+ ],
555
+ },
556
+ {sessionKey: 'agent:clawly:main'},
557
+ )
558
+
516
559
  expect(logs).toContainEqual({
517
560
  level: 'info',
518
561
  msg: expect.stringContaining('notified (session=agent:clawly:main)'),
519
562
  })
520
- expect(lastPushOpts?.body).toBe('Hey! Just checking in saw some interesting news today.')
563
+ // HEARTBEAT_OK should be stripped from body, content truncated to 140
564
+ expect(lastPushOpts?.body?.length).toBe(141)
565
+ expect(lastPushOpts?.body?.endsWith('…')).toBe(true)
521
566
  })
522
567
 
523
568
  test('sends push for normal message text', async () => {
@@ -715,7 +760,7 @@ describe('isInternalRuntimeContextTriggered', () => {
715
760
  // ── Heartbeat-triggered integration tests ────────────────────────
716
761
 
717
762
  describe('offline-push with heartbeat-triggered turns', () => {
718
- test('skips push for verbose heartbeat response', async () => {
763
+ test('skips push for verbose heartbeat response (caught by content filter before trigger check)', async () => {
719
764
  const {api, logs, handlers} = createMockApi()
720
765
  registerOfflinePush(api)
721
766
 
@@ -732,9 +777,10 @@ describe('offline-push with heartbeat-triggered turns', () => {
732
777
  {sessionKey: 'agent:clawly:main'},
733
778
  )
734
779
 
780
+ // Now caught by shouldSkipPushForMessage (content under 300 chars) before trigger check
735
781
  expect(logs).toContainEqual({
736
782
  level: 'info',
737
- msg: 'offline-push: skipped (heartbeat-triggered turn)',
783
+ msg: expect.stringContaining('skipped (filtered: heartbeat ack)'),
738
784
  })
739
785
  expect(logs.filter((l) => l.msg.includes('notified'))).toHaveLength(0)
740
786
  })
@@ -157,10 +157,17 @@ export function shouldSkipPushForMessage(text: string): string | null {
157
157
  // — the scenario is extremely unlikely and the cost is a missed push, not hidden UI.
158
158
  if (/NO_REPLY[\p{P}\s]*$/u.test(text)) return 'silent reply'
159
159
 
160
- // Heartbeat acknowledgment (HEARTBEAT_OK as ending sentinel) only skip if no substantial content before it
161
- if (/HEARTBEAT_OK[\p{P}\s]*$/u.test(text)) {
162
- const stripped = text.replace(/HEARTBEAT_OK[\p{P}\s]*$/u, '').trim()
163
- if (stripped.length === 0) return 'heartbeat ack'
160
+ // Heartbeat acknowledgment — strip HEARTBEAT_OK from both edges (mirrors
161
+ // OpenClaw's stripTokenAtEdges). Skip if remaining text ≤ ackMaxChars (300).
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)
165
+ if (hasAtEnd || hasAtStart) {
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, '')
169
+ stripped = stripped.trim()
170
+ if (stripped.length <= HEARTBEAT_ACK_MAX_CHARS) return 'heartbeat ack'
164
171
  }
165
172
 
166
173
  // Agent echoed system prompt metadata — mobile hides as "systemPromptLeak"
@@ -270,7 +277,17 @@ export function registerOfflinePush(api: PluginApi) {
270
277
  return
271
278
  }
272
279
 
273
- const noHeartbeat = fullText?.replace(/HEARTBEAT_OK[\p{P}\s]*$/u, '').trim() ?? null
280
+ const noHeartbeat =
281
+ (() => {
282
+ if (!fullText) return null
283
+ const atEnd = /HEARTBEAT_OK[\p{P}\s]*$/u.test(fullText)
284
+ const atStart = /^[\p{P}\s]*HEARTBEAT_OK/u.test(fullText)
285
+ if (!atEnd && !atStart) return fullText.trim()
286
+ let s = fullText
287
+ if (atEnd) s = s.replace(/HEARTBEAT_OK[\p{P}\s]*$/u, '')
288
+ if (atStart) s = s.replace(/^[\p{P}\s]*HEARTBEAT_OK[\p{P}\s]*/u, '')
289
+ return s.trim()
290
+ })() ?? null
274
291
  const cleaned = noHeartbeat ? stripPlaceholders(noHeartbeat) : null
275
292
  const preview = cleaned && cleaned.length > 140 ? `${cleaned.slice(0, 140)}…` : cleaned
276
293
  const body = preview || 'Your response is ready'
@@ -6,8 +6,8 @@ describe('isOnlineEntry', () => {
6
6
  expect(isOnlineEntry({host: 'openclaw-ios', reason: 'foreground'})).toBe(true)
7
7
  })
8
8
 
9
- test('returns true for reason "connect"', () => {
10
- expect(isOnlineEntry({host: 'openclaw-ios', reason: 'connect'})).toBe(true)
9
+ test('returns false for reason "connect" (WebSocket alive but not foreground)', () => {
10
+ expect(isOnlineEntry({host: 'openclaw-ios', reason: 'connect'})).toBe(false)
11
11
  })
12
12
 
13
13
  test('returns false for reason "background"', () => {
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Online presence check — queries `system-presence` via the gateway CLI
3
- * and checks if the mobile client (`openclaw-ios`) is connected.
3
+ * and checks if ANY device is in the foreground.
4
4
  *
5
- * Method: clawly.isOnline({ host? }) → { isOnline: boolean }
5
+ * Method: clawly.isOnline() → { isOnline: boolean }
6
6
  */
7
7
 
8
8
  import {$} from 'zx'
@@ -12,43 +12,38 @@ import {stripCliLogs} from '../lib/stripCliLogs'
12
12
 
13
13
  $.verbose = false
14
14
 
15
- const DEFAULT_HOST = 'openclaw-ios'
16
-
17
15
  interface PresenceEntry {
18
16
  host?: string
19
17
  reason?: string
20
18
  }
21
19
 
22
- /** Returns true if the presence entry indicates the client is connected.
20
+ /** Returns true if the presence entry indicates the user is actively viewing the app.
23
21
  *
24
- * ENG-1752: 之前只信任 'foreground' 信号,忽略了 'connect',导致 dev/nightly
25
- * 环境在 app active 时仍返回 isOnline=false,触发了不必要的 push 通知。
26
- * 恢复 'connect' 判断以修复在线检测。 */
22
+ * Only `foreground` counts — `connect` merely means the WebSocket is alive,
23
+ * which is true for backgrounded apps and should not suppress push notifications. */
27
24
  export function isOnlineEntry(entry: PresenceEntry | undefined): boolean {
28
25
  if (!entry) return false
29
- return entry.reason === 'foreground' || entry.reason === 'connect'
26
+ return entry.reason === 'foreground'
30
27
  }
31
28
 
32
29
  /**
33
30
  * Shells out to `openclaw gateway call system-presence` and checks
34
- * whether the given host has a non-"disconnect" entry.
31
+ * whether ANY device has a foreground presence entry.
35
32
  */
36
- export async function isClientOnline(host = DEFAULT_HOST): Promise<boolean> {
33
+ export async function isClientOnline(): Promise<boolean> {
37
34
  try {
38
35
  const result = await $`openclaw gateway call system-presence --json`
39
36
  const jsonStr = stripCliLogs(result.stdout)
40
37
  const entries: PresenceEntry[] = JSON.parse(jsonStr)
41
- const entry = entries.find((e) => e.host === host)
42
- return isOnlineEntry(entry)
38
+ return entries.some(isOnlineEntry)
43
39
  } catch {
44
40
  return false
45
41
  }
46
42
  }
47
43
 
48
44
  export function registerPresence(api: PluginApi) {
49
- api.registerGatewayMethod('clawly.isOnline', async ({params, respond}) => {
50
- const host = typeof params.host === 'string' ? params.host : DEFAULT_HOST
51
- const isOnline = await isClientOnline(host)
45
+ api.registerGatewayMethod('clawly.isOnline', async ({respond}) => {
46
+ const isOnline = await isClientOnline()
52
47
  respond(true, {isOnline})
53
48
  })
54
49
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@2en/clawly-plugins",
3
- "version": "1.30.0-beta.0",
3
+ "version": "1.30.0-beta.1",
4
4
  "module": "index.ts",
5
5
  "type": "module",
6
6
  "repository": {
@@ -11,22 +11,17 @@ const TOOL_NAME = 'clawly_is_user_online'
11
11
 
12
12
  const parameters: Record<string, unknown> = {
13
13
  type: 'object',
14
- properties: {
15
- host: {
16
- type: 'string',
17
- description: 'Presence host identifier (default: "openclaw-ios")',
18
- },
19
- },
14
+ properties: {},
20
15
  }
21
16
 
22
17
  export function registerIsUserOnlineTool(api: PluginApi) {
23
18
  api.registerTool({
24
19
  name: TOOL_NAME,
25
- description: "Check if the user's mobile device is currently online.",
20
+ description:
21
+ "Check if the user's mobile device is currently online (any device in foreground).",
26
22
  parameters,
27
- async execute(_toolCallId, params) {
28
- const host = typeof params.host === 'string' ? params.host : undefined
29
- const isOnline = await isClientOnline(host)
23
+ async execute() {
24
+ const isOnline = await isClientOnline()
30
25
  return {content: [{type: 'text', text: JSON.stringify({isOnline})}]}
31
26
  },
32
27
  })