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

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.
@@ -73,6 +73,11 @@ function createMockApi(): {
73
73
  return {api, logs, handlers}
74
74
  }
75
75
 
76
+ /** Event with a simple assistant message — needed since the skip-on-no-text guard. */
77
+ const eventWithReply = {
78
+ messages: [{role: 'assistant', content: 'Hello from the assistant'}],
79
+ }
80
+
76
81
  // ── Tests ────────────────────────────────────────────────────────
77
82
 
78
83
  beforeEach(() => {
@@ -89,7 +94,7 @@ describe('offline-push', () => {
89
94
  registerOfflinePush(api)
90
95
 
91
96
  const handler = handlers.get('agent_end')!
92
- await handler({}, {sessionKey: 'agent:clawly:main'})
97
+ await handler(eventWithReply, {sessionKey: 'agent:clawly:main'})
93
98
 
94
99
  expect(logs).toContainEqual({
95
100
  level: 'info',
@@ -117,8 +122,8 @@ describe('offline-push', () => {
117
122
 
118
123
  const handler = handlers.get('agent_end')!
119
124
 
120
- await handler({}, {sessionKey: 'agent:clawly:main'})
121
- await handler({}, {sessionKey: 'agent:clawly:main'})
125
+ await handler(eventWithReply, {sessionKey: 'agent:clawly:main'})
126
+ await handler(eventWithReply, {sessionKey: 'agent:clawly:main'})
122
127
 
123
128
  expect(logs.filter((l) => l.msg.includes('notified'))).toHaveLength(2)
124
129
  })
@@ -129,11 +134,11 @@ describe('offline-push', () => {
129
134
 
130
135
  const handler = handlers.get('agent_end')!
131
136
 
132
- await handler({}, {sessionKey: 'agent:clawly:main'})
137
+ await handler(eventWithReply, {sessionKey: 'agent:clawly:main'})
133
138
  expect(lastPushExtras).toEqual({badge: 1})
134
139
  expect(mockBadgeCount).toBe(1)
135
140
 
136
- await handler({}, {sessionKey: 'agent:clawly:main'})
141
+ await handler(eventWithReply, {sessionKey: 'agent:clawly:main'})
137
142
  expect(lastPushExtras).toEqual({badge: 2})
138
143
  expect(mockBadgeCount).toBe(2)
139
144
  })
@@ -143,7 +148,7 @@ describe('offline-push', () => {
143
148
  const {api, handlers} = createMockApi()
144
149
  registerOfflinePush(api)
145
150
 
146
- await handlers.get('agent_end')!({}, {sessionKey: 'agent:clawly:main'})
151
+ await handlers.get('agent_end')!(eventWithReply, {sessionKey: 'agent:clawly:main'})
147
152
 
148
153
  expect(lastPushExtras).toEqual({badge: 1})
149
154
  expect(mockBadgeCount).toBe(0) // incremented then decremented
@@ -153,7 +158,7 @@ describe('offline-push', () => {
153
158
  const {api, logs, handlers} = createMockApi()
154
159
  registerOfflinePush(api)
155
160
 
156
- await handlers.get('agent_end')!({}, {sessionKey: 'agent:clawly:telegram:12345'})
161
+ await handlers.get('agent_end')!(eventWithReply, {sessionKey: 'agent:clawly:telegram:12345'})
157
162
 
158
163
  expect(logs).toContainEqual({
159
164
  level: 'info',
@@ -166,7 +171,9 @@ describe('offline-push', () => {
166
171
  const {api, logs, handlers} = createMockApi()
167
172
  registerOfflinePush(api)
168
173
 
169
- await handlers.get('agent_end')!({}, {sessionKey: 'agent:clawly:cron:weather-check:run:abc123'})
174
+ await handlers.get('agent_end')!(eventWithReply, {
175
+ sessionKey: 'agent:clawly:cron:weather-check:run:abc123',
176
+ })
170
177
 
171
178
  expect(logs).toContainEqual({
172
179
  level: 'info',
@@ -180,7 +187,7 @@ describe('offline-push', () => {
180
187
  registerOfflinePush(api)
181
188
 
182
189
  const handler = handlers.get('agent_end')!
183
- await handler({})
190
+ await handler(eventWithReply)
184
191
 
185
192
  expect(logs).toContainEqual({
186
193
  level: 'info',
@@ -192,7 +199,10 @@ describe('offline-push', () => {
192
199
  const {api, handlers} = createMockApi()
193
200
  registerOfflinePush(api)
194
201
 
195
- await handlers.get('agent_end')!({}, {sessionKey: 'agent:clawly:main', agentId: 'luna'})
202
+ await handlers.get('agent_end')!(eventWithReply, {
203
+ sessionKey: 'agent:clawly:main',
204
+ agentId: 'luna',
205
+ })
196
206
 
197
207
  expect(lastPushOpts?.agentId).toBe('luna')
198
208
  })
@@ -201,8 +211,9 @@ describe('offline-push', () => {
201
211
  const {api, handlers} = createMockApi()
202
212
  registerOfflinePush(api)
203
213
 
204
- await handlers.get('agent_end')!({}, {sessionKey: 'agent:clawly:main'})
214
+ await handlers.get('agent_end')!(eventWithReply, {sessionKey: 'agent:clawly:main'})
205
215
 
216
+ expect(lastPushOpts).not.toBeNull()
206
217
  expect(lastPushOpts?.title).toBeUndefined()
207
218
  })
208
219
 
@@ -223,17 +234,21 @@ describe('offline-push', () => {
223
234
  expect(lastPushOpts?.body).toBe('Hi there! How can I help you today?')
224
235
  })
225
236
 
226
- test('body falls back when no messages', async () => {
227
- const {api, handlers} = createMockApi()
237
+ test('skips push when no messages (no extractable text)', async () => {
238
+ const {api, logs, handlers} = createMockApi()
228
239
  registerOfflinePush(api)
229
240
 
230
241
  await handlers.get('agent_end')!({}, {sessionKey: 'agent:clawly:main'})
231
242
 
232
- expect(lastPushOpts?.body).toBe('Your response is ready')
243
+ expect(lastPushOpts).toBeNull()
244
+ expect(logs).toContainEqual({
245
+ level: 'warn',
246
+ msg: expect.stringContaining('skipped (no extractable assistant text)'),
247
+ })
233
248
  })
234
249
 
235
- test('body falls back when messages has no assistant role', async () => {
236
- const {api, handlers} = createMockApi()
250
+ test('skips push when messages has no assistant role', async () => {
251
+ const {api, logs, handlers} = createMockApi()
237
252
  registerOfflinePush(api)
238
253
 
239
254
  await handlers.get('agent_end')!(
@@ -241,7 +256,11 @@ describe('offline-push', () => {
241
256
  {sessionKey: 'agent:clawly:main'},
242
257
  )
243
258
 
244
- expect(lastPushOpts?.body).toBe('Your response is ready')
259
+ expect(lastPushOpts).toBeNull()
260
+ expect(logs).toContainEqual({
261
+ level: 'warn',
262
+ msg: expect.stringContaining('skipped (no extractable assistant text)'),
263
+ })
245
264
  })
246
265
 
247
266
  test('body strips [[type:value]] placeholders', async () => {
@@ -580,15 +599,15 @@ describe('offline-push with filtered messages', () => {
580
599
  })
581
600
  })
582
601
 
583
- test('sends push when event has no messages (safe default)', async () => {
602
+ test('skips push when event has no messages (no extractable text)', async () => {
584
603
  const {api, logs, handlers} = createMockApi()
585
604
  registerOfflinePush(api)
586
605
 
587
606
  await handlers.get('agent_end')!({}, {sessionKey: 'agent:clawly:main'})
588
607
 
589
608
  expect(logs).toContainEqual({
590
- level: 'info',
591
- msg: expect.stringContaining('notified (session=agent:clawly:main)'),
609
+ level: 'warn',
610
+ msg: expect.stringContaining('skipped (no extractable assistant text)'),
592
611
  })
593
612
  })
594
613
  })
@@ -226,6 +226,7 @@ export function registerOfflinePush(api: PluginApi) {
226
226
 
227
227
  // Extract full assistant text for filtering and preview.
228
228
  const fullText = getLastAssistantText(event.messages)
229
+ const triggerText = getTriggeringUserText(event.messages)
229
230
 
230
231
  // Skip if the message would be filtered by the mobile UI.
231
232
  if (fullText != null) {
@@ -270,6 +271,33 @@ export function registerOfflinePush(api: PluginApi) {
270
271
  return
271
272
  }
272
273
 
274
+ // Defensive: if we can't extract assistant text, sending a generic
275
+ // "Your response is ready" is never useful — the message likely wasn't
276
+ // persisted to the transcript either, so the user opens the app to nothing.
277
+ // Log the messages structure for debugging, then bail.
278
+ if (fullText == null || fullText === '') {
279
+ const msgCount = Array.isArray(event.messages) ? event.messages.length : 'n/a'
280
+ const lastRoles = Array.isArray(event.messages)
281
+ ? event.messages
282
+ .slice(-5)
283
+ .map(
284
+ (m: any) =>
285
+ `${m?.role ?? '?'}(${typeof m?.content === 'string' ? 'str' : Array.isArray(m?.content) ? `parts:${m.content.length}` : typeof m?.content})`,
286
+ )
287
+ .join(', ')
288
+ : 'n/a'
289
+ api.logger.warn(
290
+ `offline-push: skipped (no extractable assistant text) msgCount=${msgCount} lastRoles=[${lastRoles}] triggerText=${triggerText ? `"${triggerText.slice(0, 80)}"` : 'null'}`,
291
+ )
292
+ if (isCron) markCronPushSkipped(sessionKey!, 'no extractable text', false)
293
+ captureEvent('push.skipped', {
294
+ reason: 'no_extractable_text',
295
+ is_cron: isCron,
296
+ ...(sessionKey ? {session_key: sessionKey} : {}),
297
+ })
298
+ return
299
+ }
300
+
273
301
  // Only send push for the main clawly mobile session and cron sessions —
274
302
  // skip channel sessions (telegram, slack, discord, etc.) which have their own delivery.
275
303
  if (sessionKey !== undefined && sessionKey !== 'agent:clawly:main' && !isCron) {
package/index.ts CHANGED
@@ -17,7 +17,7 @@
17
17
  * Agent tools:
18
18
  * - clawly_is_user_online — check if user's device is connected
19
19
  * - clawly_send_app_push — send a push notification to user's device
20
- * - clawly_send_image — send an image to the user (URL download or local file)
20
+ * - clawly_send_file — send a file to the user (URL or local path under $HOME/tmp)
21
21
  * - clawly_search — web search via Perplexity (replaces denied web_search)
22
22
  * - clawly_send_message — send a message to user via main session agent (supports role: user|assistant)
23
23
  *
package/outbound.ts CHANGED
@@ -18,6 +18,7 @@ import fsp from 'node:fs/promises'
18
18
  import type {IncomingMessage, ServerResponse} from 'node:http'
19
19
  import os from 'node:os'
20
20
  import path from 'node:path'
21
+ import mime from 'mime'
21
22
 
22
23
  import type {PluginApi} from './index'
23
24
  import {createAccessToken, guardHttpAuth, resolveGatewaySecret, sendJson} from './lib/httpAuth'
@@ -132,24 +133,7 @@ export function registerOutboundMethods(api: PluginApi) {
132
133
 
133
134
  // ── HTTP route: GET /clawly/file/outbound?path=<original-path> ─────────────
134
135
 
135
- const MIME: Record<string, string> = {
136
- '.mp3': 'audio/mpeg',
137
- '.wav': 'audio/wav',
138
- '.ogg': 'audio/ogg',
139
- '.m4a': 'audio/mp4',
140
- '.aac': 'audio/aac',
141
- '.flac': 'audio/flac',
142
- '.webm': 'audio/webm',
143
- '.jpg': 'image/jpeg',
144
- '.jpeg': 'image/jpeg',
145
- '.png': 'image/png',
146
- '.gif': 'image/gif',
147
- '.webp': 'image/webp',
148
- '.bmp': 'image/bmp',
149
- '.heic': 'image/heic',
150
- '.avif': 'image/avif',
151
- '.ico': 'image/x-icon',
152
- }
136
+ // Content-Type resolution via `mime` package (replaces hardcoded map)
153
137
 
154
138
  /** Directories from which direct-path serving is allowed (no hash required). */
155
139
  let allowedRoots: string[] | null = null
@@ -183,6 +167,12 @@ async function resolveOutboundFile(rawPath: string, stateDir?: string): Promise<
183
167
  return null
184
168
  }
185
169
 
170
+ export function buildContentDisposition(filename: string): string {
171
+ const asciiName = filename.replace(/[^\x20-\x7E]/g, '_').replace(/["\\]/g, '\\$&')
172
+ const utf8Name = encodeURIComponent(filename)
173
+ return `attachment; filename="${asciiName}"; filename*=UTF-8''${utf8Name}`
174
+ }
175
+
186
176
  export function registerOutboundHttpRoute(api: PluginApi) {
187
177
  const stateDir = api.runtime.state.resolveStateDir()
188
178
 
@@ -219,8 +209,7 @@ export function registerOutboundHttpRoute(api: PluginApi) {
219
209
  return
220
210
  }
221
211
 
222
- const ext = path.extname(resolved).toLowerCase()
223
- const contentType = MIME[ext] ?? 'application/octet-stream'
212
+ const contentType = mime.getType(resolved) ?? 'application/octet-stream'
224
213
  const stat = await fsp.stat(resolved)
225
214
  const total = stat.size
226
215
 
@@ -230,6 +219,15 @@ export function registerOutboundHttpRoute(api: PluginApi) {
230
219
  return
231
220
  }
232
221
 
222
+ const downloadFilename = url.searchParams.get('download')
223
+ const baseHeaders: Record<string, string | number> = {
224
+ 'Content-Type': contentType,
225
+ 'Accept-Ranges': 'bytes',
226
+ }
227
+ if (downloadFilename && path.extname(downloadFilename) === path.extname(resolved)) {
228
+ baseHeaders['Content-Disposition'] = buildContentDisposition(downloadFilename)
229
+ }
230
+
233
231
  const rangeHeader = _req.headers.range
234
232
 
235
233
  if (rangeHeader) {
@@ -241,9 +239,8 @@ export function registerOutboundHttpRoute(api: PluginApi) {
241
239
  const stream = fs.createReadStream(resolved, {start, end})
242
240
 
243
241
  res.writeHead(206, {
244
- 'Content-Type': contentType,
242
+ ...baseHeaders,
245
243
  'Content-Range': `bytes ${start}-${end}/${total}`,
246
- 'Accept-Ranges': 'bytes',
247
244
  'Content-Length': chunkSize,
248
245
  })
249
246
  stream.pipe(res)
@@ -256,9 +253,8 @@ export function registerOutboundHttpRoute(api: PluginApi) {
256
253
 
257
254
  const buffer = await fsp.readFile(resolved)
258
255
  res.writeHead(200, {
259
- 'Content-Type': contentType,
256
+ ...baseHeaders,
260
257
  'Content-Length': total,
261
- 'Accept-Ranges': 'bytes',
262
258
  })
263
259
  res.end(buffer)
264
260
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@2en/clawly-plugins",
3
- "version": "1.30.0-beta.2",
3
+ "version": "1.30.0-beta.4",
4
4
  "module": "index.ts",
5
5
  "type": "module",
6
6
  "repository": {
@@ -13,8 +13,10 @@
13
13
  "@opentelemetry/exporter-logs-otlp-http": "^0.57.0",
14
14
  "@opentelemetry/resources": "^1.30.0",
15
15
  "@opentelemetry/sdk-logs": "^0.57.0",
16
- "posthog-node": "^5.28.0",
17
16
  "file-type": "^21.3.0",
17
+ "json5": "^2.2.3",
18
+ "mime": "^4.1.0",
19
+ "posthog-node": "^5.28.0",
18
20
  "zx": "npm:zx@8.8.5-lite"
19
21
  },
20
22
  "files": [
@@ -47,8 +49,5 @@
47
49
  "extensions": [
48
50
  "./index.ts"
49
51
  ]
50
- },
51
- "devDependencies": {
52
- "json5": "^2.2.3"
53
52
  }
54
53
  }