@2en/clawly-plugins 1.24.6 → 1.24.7-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/cron-hook.ts CHANGED
@@ -2,8 +2,8 @@
2
2
  * before_tool_call hook for cron (action=add) — ensures delivery fields are
3
3
  * always set correctly, even when the LLM omits them.
4
4
  *
5
- * Forces: delivery.mode = "none" (agent uses clawly_send_message tool)
6
- * Appends: delivery instructions to payload.message
5
+ * Forces: delivery.mode = "none" (delivery is handled by the cron-delivery
6
+ * agent_end hook which injects results into the main session via chat.inject)
7
7
  * Patches: payload.kind "systemEvent" → "agentTurn"
8
8
  *
9
9
  * The cron tool name is "cron" (not "cron.create"). The LLM passes
@@ -19,27 +19,14 @@ function isRecord(v: unknown): v is UnknownRecord {
19
19
  return typeof v === 'object' && v !== null && !Array.isArray(v)
20
20
  }
21
21
 
22
- const DELIVERY_SUFFIX = [
23
- '',
24
- '---',
25
- 'DELIVERY INSTRUCTIONS (mandatory):',
26
- 'When done, you MUST deliver your result to the user:',
27
- '1. Call the clawly_send_message tool with role="assistant" and a brief, natural summary of your result.',
28
- ].join('\n')
29
-
30
22
  function patchJob(job: UnknownRecord): UnknownRecord {
31
23
  const patched: UnknownRecord = {...job}
32
24
 
33
- // Force delivery.mode = "none" — agent reports via tools explicitly
34
- patched.delivery = {mode: 'none'}
35
-
36
- // Append delivery instructions to payload.message
37
- if (isRecord(job.payload) && typeof job.payload.message === 'string') {
38
- patched.payload = {
39
- ...job.payload,
40
- message: job.payload.message + DELIVERY_SUFFIX,
41
- }
42
- }
25
+ // Force delivery.mode = "none" — delivery is handled by the cron-delivery
26
+ // agent_end hook (injects result into main session via chat.inject)
27
+ // Preserve other delivery fields (e.g. channel) if the agent set them
28
+ const existing = isRecord(job.delivery) ? job.delivery : {}
29
+ patched.delivery = {...existing, mode: 'none'}
43
30
 
44
31
  // Patch payload.kind: systemEvent → agentTurn
45
32
  if (isRecord(patched.payload) && (patched.payload as UnknownRecord).kind === 'systemEvent') {
@@ -73,5 +60,5 @@ export function registerCronHook(api: PluginApi) {
73
60
  return {params}
74
61
  })
75
62
 
76
- api.logger.info('hook: registered before_tool_call for cron add delivery enforcement')
63
+ api.logger.info('hook: registered before_tool_call for cron add (none + agentTurn enforcement)')
77
64
  }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Cron delivery via agent_end hook — injects cron session results into the
3
+ * main chat session programmatically via `chat.inject`.
4
+ *
5
+ * When a cron job completes in an isolated session, this hook:
6
+ * 1. Detects cron sessions via `ctx.sessionKey.startsWith('agent:clawly:cron:')`
7
+ * 2. Extracts the last assistant message (raw, preserving formatting)
8
+ * 3. Filters noise (empty, NO_REPLY, heartbeat-only, system message leak)
9
+ * 4. Resolves the main session key via `sessions.resolve`
10
+ * 5. Injects the result into the main session via `chat.inject`
11
+ *
12
+ * This runs independently of offline-push — both fire on agent_end but serve
13
+ * different purposes. `chat.inject` does NOT trigger a new agent_end (it's a
14
+ * transcript injection, not an agent turn), so no infinite loop risk.
15
+ */
16
+
17
+ import type {PluginApi} from '../types'
18
+ import {injectAssistantMessage, resolveSessionKey} from './inject'
19
+ import {shouldSkipPushForMessage} from './offline-push'
20
+
21
+ /**
22
+ * Extract the last assistant message's full text from a messages array.
23
+ * Preserves original formatting (newlines, whitespace) — unlike the
24
+ * collapsed version in offline-push used for notification previews.
25
+ */
26
+ function getRawLastAssistantText(messages: unknown): string | null {
27
+ if (!Array.isArray(messages)) return null
28
+
29
+ for (let i = messages.length - 1; i >= 0; i--) {
30
+ const msg = messages[i]
31
+ if (typeof msg !== 'object' || msg === null) continue
32
+ if ((msg as any).role !== 'assistant') continue
33
+
34
+ const content = (msg as any).content
35
+ if (typeof content === 'string') return content.trim()
36
+ if (Array.isArray(content)) {
37
+ const text = content
38
+ .filter((p: any) => typeof p === 'object' && p !== null && p.type === 'text')
39
+ .map((p: any) => p.text)
40
+ .join('')
41
+ return text.trim()
42
+ }
43
+ return null
44
+ }
45
+
46
+ return null
47
+ }
48
+
49
+ export function registerCronDelivery(api: PluginApi) {
50
+ api.on('agent_end', async (event: Record<string, unknown>, ctx?: Record<string, unknown>) => {
51
+ const sessionKey = typeof ctx?.sessionKey === 'string' ? ctx.sessionKey : undefined
52
+ const agentId = typeof ctx?.agentId === 'string' ? ctx.agentId : undefined
53
+
54
+ // Only fire for cron sessions
55
+ if (!sessionKey?.startsWith('agent:clawly:cron:')) return
56
+
57
+ try {
58
+ // Extract raw assistant text (preserving formatting)
59
+ const text = getRawLastAssistantText(event.messages)
60
+ if (text == null) {
61
+ api.logger.info('cron-delivery: skipped (no assistant message)')
62
+ return
63
+ }
64
+
65
+ // Filter noise — reuse the same logic as offline-push
66
+ const reason = shouldSkipPushForMessage(text)
67
+ if (reason) {
68
+ api.logger.info(`cron-delivery: skipped (filtered: ${reason})`)
69
+ return
70
+ }
71
+
72
+ // Resolve main session key for this agent
73
+ if (!agentId) {
74
+ api.logger.error('cron-delivery: skipped (no agentId on ctx)')
75
+ return
76
+ }
77
+
78
+ const mainSessionKey = await resolveSessionKey(agentId, api)
79
+
80
+ // Inject the cron result into the main session
81
+ const result = await injectAssistantMessage(
82
+ {
83
+ sessionKey: mainSessionKey,
84
+ message: text,
85
+ label: 'cron',
86
+ },
87
+ api,
88
+ )
89
+
90
+ api.logger.info(
91
+ `cron-delivery: injected into ${mainSessionKey} (messageId=${result.messageId})`,
92
+ )
93
+ } catch (err) {
94
+ api.logger.error(`cron-delivery: ${err instanceof Error ? err.message : String(err)}`)
95
+ }
96
+ })
97
+
98
+ api.logger.info('cron-delivery: registered agent_end hook')
99
+ }
package/gateway/index.ts CHANGED
@@ -2,6 +2,7 @@ import type {PluginApi} from '../types'
2
2
  import {registerAgentSend} from './agent'
3
3
  import {registerClawhub2gateway} from './clawhub2gateway'
4
4
  import {registerConfigRepair} from './config-repair'
5
+ import {registerCronDelivery} from './cron-delivery'
5
6
  import {registerPairing} from './pairing'
6
7
  import {registerMemoryBrowser} from './memory'
7
8
  import {registerNotification} from './notification'
@@ -18,6 +19,7 @@ export function registerGateway(api: PluginApi) {
18
19
  registerClawhub2gateway(api)
19
20
  registerPlugins(api)
20
21
  registerOfflinePush(api)
22
+ registerCronDelivery(api)
21
23
  registerConfigRepair(api)
22
24
  registerPairing(api)
23
25
  registerVersion(api)
@@ -92,7 +92,10 @@ describe('offline-push', () => {
92
92
  await handlers.get('agent_end')!({}, {sessionKey: 'agent:clawly:main'})
93
93
 
94
94
  expect(logs.filter((l) => l.msg.includes('notified'))).toHaveLength(0)
95
- expect(logs.filter((l) => l.msg.includes('skipped'))).toHaveLength(0)
95
+ expect(logs).toContainEqual({
96
+ level: 'info',
97
+ msg: expect.stringContaining('skipped (client online)'),
98
+ })
96
99
  })
97
100
 
98
101
  test('sends push for consecutive calls on same session', async () => {
@@ -120,6 +123,19 @@ describe('offline-push', () => {
120
123
  expect(logs.filter((l) => l.msg.includes('notified'))).toHaveLength(0)
121
124
  })
122
125
 
126
+ test('sends push for cron session', async () => {
127
+ const {api, logs, handlers} = createMockApi()
128
+ registerOfflinePush(api)
129
+
130
+ await handlers.get('agent_end')!({}, {sessionKey: 'agent:clawly:cron:weather-check:run:abc123'})
131
+
132
+ expect(logs).toContainEqual({
133
+ level: 'info',
134
+ msg: expect.stringContaining('notified (session=agent:clawly:cron:weather-check:run:abc123)'),
135
+ })
136
+ expect(logs.filter((l) => l.msg.includes('skipped'))).toHaveLength(0)
137
+ })
138
+
123
139
  test('sends push when ctx is missing', async () => {
124
140
  const {api, logs, handlers} = createMockApi()
125
141
  registerOfflinePush(api)
@@ -188,6 +204,52 @@ describe('offline-push', () => {
188
204
 
189
205
  expect(lastPushOpts?.body).toBe('Your response is ready')
190
206
  })
207
+
208
+ test('body strips [[type:value]] placeholders', async () => {
209
+ const {api, handlers} = createMockApi()
210
+ registerOfflinePush(api)
211
+
212
+ await handlers.get('agent_end')!(
213
+ {
214
+ messages: [
215
+ {role: 'assistant', content: 'Here is your audio [[tts:/res/audio.mp3]] enjoy!'},
216
+ ],
217
+ },
218
+ {sessionKey: 'agent:clawly:main'},
219
+ )
220
+
221
+ expect(lastPushOpts?.body).toBe('Here is your audio enjoy!')
222
+ })
223
+
224
+ test('body strips bare [[word]] placeholders', async () => {
225
+ const {api, handlers} = createMockApi()
226
+ registerOfflinePush(api)
227
+
228
+ await handlers.get('agent_end')!(
229
+ {
230
+ messages: [
231
+ {role: 'assistant', content: 'Got it [[reply_to_current]] I will remember that.'},
232
+ ],
233
+ },
234
+ {sessionKey: 'agent:clawly:main'},
235
+ )
236
+
237
+ expect(lastPushOpts?.body).toBe('Got it I will remember that.')
238
+ })
239
+
240
+ test('body strips MEDIA:xxx placeholders', async () => {
241
+ const {api, handlers} = createMockApi()
242
+ registerOfflinePush(api)
243
+
244
+ await handlers.get('agent_end')!(
245
+ {
246
+ messages: [{role: 'assistant', content: 'Check this out MEDIA:/res/image.png pretty cool'}],
247
+ },
248
+ {sessionKey: 'agent:clawly:main'},
249
+ )
250
+
251
+ expect(lastPushOpts?.body).toBe('Check this out pretty cool')
252
+ })
191
253
  })
192
254
 
193
255
  // ── getLastAssistantText / getLastAssistantPreview unit tests ────
@@ -303,6 +365,15 @@ describe('shouldSkipPushForMessage', () => {
303
365
  ).toBe('system prompt leak')
304
366
  })
305
367
 
368
+ test('skips system message leak', () => {
369
+ expect(shouldSkipPushForMessage('[System Message] Agent orchestration update')).toBe(
370
+ 'system message leak',
371
+ )
372
+ expect(shouldSkipPushForMessage('Some text with [System Message] embedded')).toBe(
373
+ 'system message leak',
374
+ )
375
+ })
376
+
306
377
  test('does not skip normal assistant messages', () => {
307
378
  expect(shouldSkipPushForMessage('Hey! How can I help you?')).toBeNull()
308
379
  expect(shouldSkipPushForMessage('Here is the weather report for today.')).toBeNull()
@@ -13,6 +13,11 @@ import type {PluginApi} from '../types'
13
13
  import {sendPushNotification} from './notification'
14
14
  import {isClientOnline} from './presence'
15
15
 
16
+ /** Strip [[type:value]], [[word]], and MEDIA:xxx placeholders from text (canonical: apps/mobile/lib/stripPlaceholders.ts). */
17
+ function stripPlaceholders(text: string): string {
18
+ return text.replace(/\[\[\w+(?::[^\]]+)?\]\]|MEDIA:\S+/g, '').trim()
19
+ }
20
+
16
21
  /**
17
22
  * Extract the last assistant message's full text from a messages array.
18
23
  * Handles both string and `{type:'text', text}[]` content formats.
@@ -88,6 +93,9 @@ export function shouldSkipPushForMessage(text: string): string | null {
88
93
  // Agent echoed system prompt metadata — mobile hides as "systemPromptLeak"
89
94
  if (text.includes('Conversation info (untrusted metadata)')) return 'system prompt leak'
90
95
 
96
+ // Agent orchestration messages leaking into chat — mobile hides as "systemMessageLeak"
97
+ if (text.includes('[System Message]')) return 'system message leak'
98
+
91
99
  return null
92
100
  }
93
101
 
@@ -96,7 +104,10 @@ export function registerOfflinePush(api: PluginApi) {
96
104
  try {
97
105
  // Skip if client is still connected — they got the response in real-time.
98
106
  const online = await isClientOnline()
99
- if (online) return
107
+ if (online) {
108
+ api.logger.info('offline-push: skipped (client online)')
109
+ return
110
+ }
100
111
 
101
112
  // Extract full assistant text for filtering and preview.
102
113
  const fullText = getLastAssistantText(event.messages)
@@ -114,14 +125,19 @@ export function registerOfflinePush(api: PluginApi) {
114
125
  const sessionKey = typeof ctx?.sessionKey === 'string' ? ctx.sessionKey : undefined
115
126
  const agentId = typeof ctx?.agentId === 'string' ? ctx.agentId : undefined
116
127
 
117
- // Only send push for the main clawly mobile session skip channel
118
- // sessions (telegram, slack, discord, etc.) which have their own delivery.
119
- if (sessionKey !== undefined && sessionKey !== 'agent:clawly:main') {
128
+ // Only send push for the main clawly mobile session and cron sessions —
129
+ // skip channel sessions (telegram, slack, discord, etc.) which have their own delivery.
130
+ if (
131
+ sessionKey !== undefined &&
132
+ sessionKey !== 'agent:clawly:main' &&
133
+ !sessionKey.startsWith('agent:clawly:cron:')
134
+ ) {
120
135
  api.logger.info(`offline-push: skipped (non-main session: ${sessionKey})`)
121
136
  return
122
137
  }
123
138
 
124
- const cleaned = fullText?.replace(/HEARTBEAT_OK[\p{P}\s]*$/u, '').trim() ?? null
139
+ const noHeartbeat = fullText?.replace(/HEARTBEAT_OK[\p{P}\s]*$/u, '').trim() ?? null
140
+ const cleaned = noHeartbeat ? stripPlaceholders(noHeartbeat) : null
125
141
  const preview = cleaned && cleaned.length > 140 ? `${cleaned.slice(0, 140)}…` : cleaned
126
142
  const body = preview || 'Your response is ready'
127
143
 
@@ -22,6 +22,7 @@ const PLUGIN_SENTINEL = 'openclaw.plugin.json'
22
22
  interface NpmViewCache {
23
23
  version: string
24
24
  versions: string[]
25
+ distTags: Record<string, string>
25
26
  }
26
27
 
27
28
  const npmCache = new LruCache<NpmViewCache>({maxSize: 5, ttlMs: 5 * 60 * 1000})
@@ -50,6 +51,10 @@ async function fetchNpmView(npmPkgName: string): Promise<NpmViewCache | null> {
50
51
  const entry: NpmViewCache = {
51
52
  version: typeof parsed.version === 'string' ? parsed.version : '',
52
53
  versions: Array.isArray(parsed.versions) ? parsed.versions : [],
54
+ distTags:
55
+ parsed['dist-tags'] && typeof parsed['dist-tags'] === 'object'
56
+ ? (parsed['dist-tags'] as Record<string, string>)
57
+ : {},
53
58
  }
54
59
  npmCache.set(npmPkgName, entry)
55
60
  return entry
@@ -144,6 +149,7 @@ export function registerPlugins(api: PluginApi) {
144
149
  api.logger.info(`plugins.version: checking ${pluginId} (${npmPkgName})`)
145
150
  let latestNpmVersion: string | null = null
146
151
  let allNpmVersions: string[] = []
152
+ let npmDistTags: Record<string, string> = {}
147
153
  let updateAvailable = false
148
154
  const errors: string[] = []
149
155
 
@@ -157,6 +163,7 @@ export function registerPlugins(api: PluginApi) {
157
163
  if (npm) {
158
164
  latestNpmVersion = npm.version
159
165
  allNpmVersions = npm.versions
166
+ npmDistTags = npm.distTags
160
167
  api.logger.info(
161
168
  `plugins.version: npm registry latest=${latestNpmVersion}, ${allNpmVersions.length} versions available`,
162
169
  )
@@ -182,6 +189,7 @@ export function registerPlugins(api: PluginApi) {
182
189
  npmPackageVersion,
183
190
  latestNpmVersion,
184
191
  allNpmVersions,
192
+ npmDistTags,
185
193
  updateAvailable,
186
194
  ...(errors.length ? {error: errors.join('; ')} : {}),
187
195
  })
@@ -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" (backward compat)', () => {
10
- expect(isOnlineEntry({host: 'openclaw-ios', reason: 'connect'})).toBe(true)
9
+ test('returns false for reason "connect" (gateway WS state, not reliable for app foreground)', () => {
10
+ expect(isOnlineEntry({host: 'openclaw-ios', reason: 'connect'})).toBe(false)
11
11
  })
12
12
 
13
13
  test('returns false for reason "background"', () => {
@@ -19,10 +19,12 @@ interface PresenceEntry {
19
19
  reason?: string
20
20
  }
21
21
 
22
- /** Returns true if the presence entry indicates the client is actively connected. */
22
+ /** Returns true if the presence entry indicates the client is actively in the foreground.
23
+ * Only trusts the explicit 'foreground' signal from the mobile app — 'connect'
24
+ * (gateway WS state) can linger after the app backgrounds, causing false positives. */
23
25
  export function isOnlineEntry(entry: PresenceEntry | undefined): boolean {
24
26
  if (!entry) return false
25
- return entry.reason === 'foreground' || entry.reason === 'connect'
27
+ return entry.reason === 'foreground'
26
28
  }
27
29
 
28
30
  /**
package/index.ts CHANGED
@@ -25,7 +25,7 @@
25
25
  * - before_message_write — restores original /skill command in user messages (undoes gateway rewrite)
26
26
  * - tool_result_persist — copies TTS audio to persistent outbound directory
27
27
  * - before_tool_call — enforces delivery fields on cron.create
28
- * - agent_end — sends push notification when client is offline
28
+ * - agent_end — sends push notification when client is offline; injects cron results into main session
29
29
  * - gateway_start — auto-approves device pairing for Clawly mobile clients (clientId: openclaw-ios)
30
30
  * - gateway_start — registers auto-update cron job (0 3 * * *) for clawly-plugins
31
31
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@2en/clawly-plugins",
3
- "version": "1.24.6",
3
+ "version": "1.24.7-beta.1",
4
4
  "module": "index.ts",
5
5
  "type": "module",
6
6
  "repository": {