@2en/clawly-plugins 1.30.0-beta.1 → 1.30.0-beta.11

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.
@@ -2,14 +2,15 @@
2
2
  * Timezone sync RPC: writes agents.defaults.userTimezone to openclaw.json
3
3
  * without triggering a gateway restart.
4
4
  *
5
+ * Uses the runtime config APIs (loadConfig + writeConfigFile) for atomic
6
+ * writes, env var preservation, and config validation.
7
+ *
5
8
  * Methods:
6
9
  * - clawly.config.setTimezone({ timezone }) → { changed, timezone }
7
10
  */
8
11
 
9
- import path from 'node:path'
10
-
11
12
  import type {PluginApi} from '../types'
12
- import {readOpenclawConfig, writeOpenclawConfig} from '../model-gateway-setup'
13
+ import type {OpenClawConfig} from '../types/openclaw'
13
14
 
14
15
  export function registerConfigTimezone(api: PluginApi) {
15
16
  api.registerGatewayMethod('clawly.config.setTimezone', async ({params, respond}) => {
@@ -19,30 +20,34 @@ export function registerConfigTimezone(api: PluginApi) {
19
20
  return
20
21
  }
21
22
 
22
- const stateDir = api.runtime.state.resolveStateDir()
23
- if (!stateDir) {
24
- respond(true, {changed: false, timezone, error: 'Cannot resolve state dir'})
23
+ let config: OpenClawConfig
24
+ try {
25
+ config = {...(api.runtime.config.loadConfig() as OpenClawConfig)}
26
+ } catch (err) {
27
+ const msg = err instanceof Error ? err.message : String(err)
28
+ respond(true, {changed: false, timezone, error: `Load failed: ${msg}`})
25
29
  return
26
30
  }
27
31
 
28
- const configPath = path.join(stateDir, 'openclaw.json')
29
- const config = readOpenclawConfig(configPath)
30
-
31
- const agents = (config.agents ?? {}) as Record<string, unknown>
32
- const defaults = (agents.defaults ?? {}) as Record<string, unknown>
33
- const current = defaults.userTimezone
32
+ const currentDefaults = (config.agents as Record<string, unknown> | undefined)?.defaults as
33
+ | Record<string, unknown>
34
+ | undefined
34
35
 
35
- if (current === timezone) {
36
+ if (currentDefaults?.userTimezone === timezone) {
36
37
  respond(true, {changed: false, timezone})
37
38
  return
38
39
  }
39
40
 
41
+ // Shallow-copy nested objects to avoid polluting the loadConfig() cache
42
+ // if writeConfigFile fails below.
43
+ const agents = {...((config.agents ?? {}) as Record<string, unknown>)}
44
+ const defaults = {...((agents.defaults ?? {}) as Record<string, unknown>)}
40
45
  defaults.userTimezone = timezone
41
46
  agents.defaults = defaults
42
47
  config.agents = agents
43
48
 
44
49
  try {
45
- writeOpenclawConfig(configPath, config)
50
+ await api.runtime.config.writeConfigFile(config)
46
51
  api.logger.info(`config-timezone: set userTimezone to ${timezone}`)
47
52
  respond(true, {changed: true, timezone})
48
53
  } catch (err) {
@@ -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)
package/gateway/index.ts CHANGED
@@ -3,8 +3,8 @@ import {registerAgentSend} from './agent'
3
3
  import {registerCalendarNative} from './calendar-native'
4
4
  import {registerAnalytics} from './analytics'
5
5
  import {registerAudit} from './audit'
6
- import {registerNodeDangerousAllowlist} from './node-dangerous-allowlist'
7
6
  import {registerClawhub2gateway} from './clawhub2gateway'
7
+ import {registerConfigModel} from './config-model'
8
8
  import {registerConfigRepair} from './config-repair'
9
9
  import {registerConfigTimezone} from './config-timezone'
10
10
  import {registerCronDelivery} from './cron-delivery'
@@ -58,12 +58,12 @@ export function registerGateway(api: PluginApi) {
58
58
  registerCronTelemetry(api)
59
59
  registerMessageLog(api)
60
60
  registerAnalytics(api)
61
+ registerConfigModel(api)
61
62
  registerConfigRepair(api)
62
63
  registerConfigTimezone(api)
63
64
  registerSessionSanitize(api)
64
65
  registerPairing(api)
65
66
  registerVersion(api)
66
67
  registerAudit(api)
67
- registerNodeDangerousAllowlist(api)
68
68
  registerCalendarNative(api)
69
69
  }
@@ -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 () => {
@@ -427,6 +446,22 @@ describe('shouldSkipPushForMessage', () => {
427
446
  expect(shouldSkipPushForMessage('The token HEARTBEAT_OK is used for health checks.')).toBeNull()
428
447
  })
429
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
+
430
465
  test('skips system prompt leak', () => {
431
466
  expect(
432
467
  shouldSkipPushForMessage('Here is some Conversation info (untrusted metadata) text'),
@@ -565,6 +600,33 @@ describe('offline-push with filtered messages', () => {
565
600
  expect(lastPushOpts?.body?.endsWith('…')).toBe(true)
566
601
  })
567
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
+
568
630
  test('sends push for normal message text', async () => {
569
631
  const {api, logs, handlers} = createMockApi()
570
632
  registerOfflinePush(api)
@@ -580,15 +642,15 @@ describe('offline-push with filtered messages', () => {
580
642
  })
581
643
  })
582
644
 
583
- test('sends push when event has no messages (safe default)', async () => {
645
+ test('skips push when event has no messages (no extractable text)', async () => {
584
646
  const {api, logs, handlers} = createMockApi()
585
647
  registerOfflinePush(api)
586
648
 
587
649
  await handlers.get('agent_end')!({}, {sessionKey: 'agent:clawly:main'})
588
650
 
589
651
  expect(logs).toContainEqual({
590
- level: 'info',
591
- msg: expect.stringContaining('notified (session=agent:clawly:main)'),
652
+ level: 'warn',
653
+ msg: expect.stringContaining('skipped (no extractable assistant text)'),
592
654
  })
593
655
  })
594
656
  })
@@ -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
  }
@@ -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) {
@@ -280,12 +308,12 @@ export function registerOfflinePush(api: PluginApi) {
280
308
  const noHeartbeat =
281
309
  (() => {
282
310
  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)
311
+ const atEnd = /HEARTBEAT[_ ]OK[\p{P}\s]*$/u.test(fullText)
312
+ const atStart = /^[\p{P}\s]*HEARTBEAT[_ ]OK/u.test(fullText)
285
313
  if (!atEnd && !atStart) return fullText.trim()
286
314
  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, '')
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, '')
289
317
  return s.trim()
290
318
  })() ?? null
291
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
@@ -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
  *
@@ -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 {
@@ -105,7 +109,12 @@ export function initCache(): void {
105
109
  }
106
110
 
107
111
  export function getCache(): CalendarCache | null {
108
- if (!cachedData) cachedData = loadCache()
112
+ // Always read from disk to avoid returning stale in-memory state.
113
+ // After gateway restart or ESM/CJS dual-loading, gateway methods and
114
+ // agent tools may hold separate module instances with divergent
115
+ // cachedData. Disk is the shared source of truth. The file is small
116
+ // (<10 KB) and tool calls are infrequent, so the I/O cost is negligible.
117
+ cachedData = loadCache()
109
118
  return cachedData
110
119
  }
111
120
 
@@ -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/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.1",
3
+ "version": "1.30.0-beta.11",
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
  }